JavaScript: DOM, 10–13
Finding, creating and changing elements.
Exercises 10 to 13 of 13
10 A sphere volume calculator
fairRead a radius from a form field and show the volume of a sphere, 4/3 π r³.
Stuck? Three hints, smallest first
- 1. An input’s
valueis always a string, even ontype="number". - 2.
Number(‘’)is0, so an empty field validates as a perfectly good zero. - 3. Keep the calculation in a pure function and let the handler do only the reading and writing.
allow-scripts only, so it cannot reach this page.// Pure. No DOM, so it can be checked directly.
const sphereVolume = (radius) => (4 / 3) * Math.PI * radius ** 3;
// Parsing and validating, kept apart from both the maths and the DOM.
function parseRadius(text) {
if (typeof text !== 'string' || text.trim() === '') return { ok: false, why: 'Enter a radius.' };
const n = Number(text);
if (!Number.isFinite(n)) return { ok: false, why: `${JSON.stringify(text)} is not a number.` };
if (n <= 0) return { ok: false, why: 'The radius must be above zero.' };
return { ok: true, value: n };
}
const input = el('input', { id: 'radius', value: '3' });
const out = el('p', { id: 'out' });
const button = el('button', { id: 'go' }, 'Calculate');
document.body.appendChild(el('form', {}, [input, button, out]));
button.addEventListener('click', () => {
const parsed = parseRadius(input.value);
out.textContent = parsed.ok ? `Volume = ${sphereVolume(parsed.value).toFixed(4)}` : parsed.why;
});
console.log('value is a string:', typeof input.value, JSON.stringify(input.value));
fire(button, 'click');
console.log('r=3 ->', out.textContent);
for (const bad of ['', ' ', 'abc', '-2', '0']) {
input.value = bad;
fire(button, 'click');
console.log(JSON.stringify(bad).padEnd(7), '->', out.textContent);
}
console.log("Number('') is", Number(''), '<- which is why the trim guard exists');
Output
14 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 14 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the volume for r=3', sphereVolume(3).toFixed(4), '113.0973');
test('for r=1 it is 4/3 pi', sphereVolume(1), (4 / 3) * Math.PI);
test('doubling the radius multiplies volume by 8', sphereVolume(4) / sphereVolume(2), 8);
test('an input value is a string, not a number', typeof input.value, 'string');
test('the handler wrote the answer into the page', (() => { input.value = '3'; fire(button, 'click'); return out.textContent; })(), 'Volume = 113.0973');
test('an empty field is refused', parseRadius('').ok, false);
test('with a message that says what to do', parseRadius('').why, 'Enter a radius.');
test('a field of spaces is refused too', parseRadius(' ').ok, false);
test('because Number("") would be 0, not NaN', Number(''), 0);
test('text is refused', parseRadius('abc').ok, false);
test('zero is refused', parseRadius('0').ok, false);
test('a negative radius is refused', parseRadius('-2').ok, false);
test('a decimal radius is accepted', parseRadius('2.5').value, 2.5);
test('and the page shows its error, not a number', (() => { input.value = 'abc'; fire(button, 'click'); return out.textContent; })(), '"abc" is not a number.');
An input's value is always a string — even type="number" hands you "3", and the assertion checks it. So every form field needs converting, and conversion needs validating.
Number('') is 0, not NaN. That single fact is why the trim() !== '' guard comes before the numeric check: without it, an empty field is a valid zero radius and your calculator confidently reports a volume of 0. A field containing only spaces does the same thing.
Splitting this into sphereVolume, parseRadius and a handler is worth the extra lines. The maths can be checked against known values, the parsing can be checked against bad input, and only the three-line handler touches the DOM at all.
11 Show a random image
fairPick a random image from a list and display it, with its width and height, when a button is pressed.
Stuck? Three hints, smallest first
- 1.
Math.floor(Math.random() * list.length)gives a valid index. Notefloor, notround. - 2. Randomness cannot be asserted directly. Inject the random function so a test can supply a known one.
- 3. Always set
alt, and setwidthandheightso the page does not jump as the image loads.
allow-scripts only, so it cannot reach this page.// Inline SVG data URLs, so nothing is fetched. The original lists three Flickr
// URLs; those links are dead, and loading them would break the offline promise.
const swatch = (fill, w, h) =>
'data:image/svg+xml;utf8,' + encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">` +
`<rect width="100%" height="100%" fill="${fill}"/></svg>`);
const images = [
{ src: swatch('#3055d8', 240, 160), width: 240, height: 160, alt: 'A blue placeholder, 240 by 160' },
{ src: swatch('#17734b', 320, 195), width: 320, height: 195, alt: 'A green placeholder, 320 by 195' },
{ src: swatch('#a3341f', 500, 343), width: 500, height: 343, alt: 'A red placeholder, 500 by 343' },
];
// random is a PARAMETER. That is what makes a random choice testable: the page
// passes Math.random, a test passes something it controls.
function showRandomImage(host, list, random = Math.random) {
const pick = list[Math.floor(random() * list.length)]; // floor, never round
host.textContent = '';
const img = document.createElement('img');
img.src = pick.src;
img.width = pick.width; // stated so the layout does not jump
img.height = pick.height;
img.alt = pick.alt; // never omit this
host.appendChild(img);
return img;
}
const host = el('p', { id: 'host' });
document.body.appendChild(host);
// A fixed sequence stands in for randomness.
let i = 0;
const sequence = [0, 0.5, 0.99];
const nextFixed = () => sequence[i++ % sequence.length];
for (const _ of sequence) {
const img = showRandomImage(host, images, nextFixed);
console.log('picked', img.getAttribute('width') + '\u00d7' + img.getAttribute('height'), '| alt:', img.alt);
}
// Why floor and not round: round would reach an index that does not exist.
console.log('Math.round(0.99 * 3) =', Math.round(0.99 * 3), '<- index 3 of 3 is undefined');
console.log('Math.floor(0.99 * 3) =', Math.floor(0.99 * 3), '<- always in range');
Output
13 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 13 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('0 picks the first', showRandomImage(host, images, () => 0).alt, images[0].alt);
test('0.5 picks the middle', showRandomImage(host, images, () => 0.5).alt, images[1].alt);
test('0.99 picks the last', showRandomImage(host, images, () => 0.99).alt, images[2].alt);
test('floor keeps the index in range', Math.floor(0.999999 * images.length), 2);
test('round would go out of range', Math.round(0.99 * images.length), 3);
test('and that index does not exist', images[3], undefined);
test('the image has a width', showRandomImage(host, images, () => 0).getAttribute('width'), '240');
test('and a height', showRandomImage(host, images, () => 0).getAttribute('height'), '160');
test('and a non-empty alt', showRandomImage(host, images, () => 0).alt.length > 0, true);
test('the source is a data URL, so nothing is fetched', showRandomImage(host, images, () => 0).src.startsWith('data:'), true);
test('only one image is shown at a time', host.children.length, 1);
test('a real Math.random always lands in range', Array.from({ length: 200 }, () => Math.floor(Math.random() * images.length)).every((n) => n >= 0 && n < images.length), true);
test('and every image is reachable', new Set(Array.from({ length: 400 }, () => Math.floor(Math.random() * images.length))).size, 3);
Adapted, twice over. The original's three Flickr URLs are dead, and fetching anything would break this course's promise that it works from a file:// URL with no network — so the images are inline SVG data URLs. And Math.random is a parameter with a default, which is the general technique for testing anything random: the page gets real randomness, the test supplies () => 0.5 and asserts an exact result.
Math.floor, never Math.round. With three images, round(0.99 * 3) is 3, and index 3 does not exist — the assertions pin down both the failure and the fix. This is the standard off-by-one in random selection.
width, height and alt are all load-bearing. The dimensions reserve space so the page does not jump as the image arrives, and alt is the only thing a screen-reader user gets.
12 Highlight the bold words on hover
fairHovering a link should highlight every bold word in a paragraph, and moving away should remove the highlight.
Stuck? Three hints, smallest first
- 1.
querySelectorAll(‘b’)finds them all; loop over the result. - 2. Pair
mouseoverwithmouseout, or the highlight never leaves. - 3. Add
focusandbluras well, or keyboard users get nothing.
allow-scripts only, so it cannot reach this page.const words = ['section', 'users', 'problems', 'skill'];
const para = el('p', { id: 'para' }, [
'We have just started this ', el('b', {}, 'section'),
' for the ', el('b', {}, 'users'),
' who want to work with various JavaScript ', el('b', {}, 'problems'),
' and write scripts online to test their JavaScript ', el('b', {}, 'skill'), '.',
]);
const trigger = el('a', { href: '#', id: 'trigger' }, 'Hover here');
document.body.appendChild(el('p', {}, trigger));
document.body.appendChild(para);
const bolds = para.querySelectorAll('b');
function paint(on) {
for (const b of bolds) {
b.style.backgroundColor = on ? '#fff3bf' : '';
b.style.outline = on ? '2px solid #f0c000' : '';
}
}
trigger.addEventListener('mouseover', () => paint(true));
trigger.addEventListener('mouseout', () => paint(false));
// A keyboard user never fires mouseover. Without these two they get nothing.
trigger.addEventListener('focus', () => paint(true));
trigger.addEventListener('blur', () => paint(false));
// querySelectorAll gives a NodeList: it has forEach, but not filter or map.
const highlighted = () => [...bolds].filter((b) => b.style.backgroundColor === '#fff3bf').length;
console.log('bold words found:', bolds.length, [...bolds].map((b) => b.textContent));
console.log('highlighted at rest:', highlighted());
fire(trigger, 'mouseover');
console.log('after mouseover :', highlighted());
fire(trigger, 'mouseout');
console.log('after mouseout :', highlighted());
fire(trigger, 'focus');
console.log('after focus :', highlighted(), '<- keyboard users get the same');
fire(trigger, 'blur');
console.log('after blur :', highlighted());
Output
15 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 15 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('all four bold words are found', bolds.length, 4);
test('and they are the right words', [...bolds].map((b) => b.textContent), words);
test('nothing is highlighted at rest', highlighted(), 0);
test('mouseover highlights all of them', (() => { fire(trigger, 'mouseover'); return highlighted(); })(), 4);
test('an outline is added too', bolds[0].style.outline, '2px solid #f0c000');
test('mouseout removes the highlight', (() => { fire(trigger, 'mouseout'); return highlighted(); })(), 0);
test('and clears the outline', bolds[0].style.outline, '');
test('focus does the same as hover', (() => { fire(trigger, 'focus'); return highlighted(); })(), 4);
test('blur undoes it', (() => { fire(trigger, 'blur'); return highlighted(); })(), 0);
test('the text is never changed', para.textContent.includes('section'), true);
test('and no elements were added or removed', para.querySelectorAll('b').length, 4);
test('a paragraph with no bold words is handled', para.querySelectorAll('strong').length, 0);
test('a NodeList has forEach', typeof bolds.forEach, 'function');
test('but not map, so it must be spread', typeof bolds.map, 'undefined');
test('and it is not an array', Array.isArray(bolds), false);
Two pairs of listeners, not one. mouseover without mouseout leaves the highlight stuck on. And focus/blur alongside them is what stops this being mouse-only: a keyboard user tabbing to the link fires focus and never mouseover, so without those two the feature simply does not exist for them. The assertions check both routes.
Worth knowing that this particular effect needs no JavaScript at all. #trigger:hover ~ #para b, #trigger:focus ~ #para b { background: #fff3bf } does the same job in CSS, and it keeps working if the script fails to load. Reach for JavaScript when the state has to outlive the interaction — a class you toggle and leave on, for instance.
13 Report the window size as it changes
fairShow the window's width and height, and keep it up to date as the window is resized.
Stuck? Three hints, smallest first
- 1.
window.innerWidthandinnerHeightinclude the scrollbar;documentElement.clientWidthdoes not. - 2. Resize fires continuously while dragging, so do not do real work on every event.
- 3. Report the size once at the start too, or the display is empty until the first resize.
allow-scripts only, so it cannot reach this page.// ---- the stand-in ----------------------------------------------------------
// A debounce needs a timer the test can drive, so the clock is injected. The
// window stand-in has resizeTo(w, h), which sets the size and fires 'resize' —
// a real window has no such method; it is how a test resizes without a browser.
function makeClock() {
let now = 0;
let jobs = [];
let nextId = 1;
return {
setTimeout(fn, ms) { const id = nextId++; jobs.push({ id, fn, at: now + ms }); return id; },
clearTimeout(id) { jobs = jobs.filter((j) => j.id !== id); },
advance(ms) {
now += ms;
const due = jobs.filter((j) => j.at <= now).sort((a, b) => a.at - b.at);
jobs = jobs.filter((j) => j.at > now);
for (const job of due) job.fn();
},
};
}
// ---- the answer ------------------------------------------------------------
const out = el('pre', { id: 'out' });
document.body.appendChild(out);
const clock = makeClock();
let rawEvents = 0;
let workDone = 0;
const size = () => ({ width: window.innerWidth, height: window.innerHeight });
function report() {
const { width, height } = size();
out.textContent = `width ${width}\nheight ${height}`;
}
let timer = null;
window.addEventListener('resize', () => {
rawEvents++;
report(); // cheap: always fine
clock.clearTimeout(timer);
timer = clock.setTimeout(() => { workDone++; }, 150); // expensive: debounced
});
report(); // once up front, so it is never blank
// Snapshotted NOW. Asserting against out.textContent later would read the value
// after the resizes below, which is a different claim.
const atRest = out.textContent;
console.log('at rest :', atRest.replace('\n', ', '));
// A drag fires many events in quick succession.
for (const [w, h] of [[1000, 700], [900, 650], [800, 600], [760, 580]]) {
window.resizeTo(w, h);
clock.advance(20); // still mid-drag
}
console.log('during a drag : events', rawEvents, 'work', workDone);
clock.advance(200); // the drag stopped
console.log('after it settles: events', rawEvents, 'work', workDone);
console.log('final size :', out.textContent.replace('\n', ', '));
Output
13 checks that prove this answer
You do not have to read these. They are part of the answer panel above, so pressing
Run there executes all 13 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the size is reported before any resize', atRest.startsWith('width 1024'), true);
test('the initial height too', atRest.includes('height 768'), true);
test('four resize events fired', rawEvents, 4);
test('but the expensive work ran once', workDone, 1);
test('so the debounce collapsed four into one', rawEvents > workDone, true);
test('the display shows the final width', out.textContent.includes('width 760'), true);
test('and the final height', out.textContent.includes('height 580'), true);
test('window.innerWidth agrees', window.innerWidth, 760);
test('and innerHeight', window.innerHeight, 580);
test('a further resize is reported immediately', (() => { window.resizeTo(500, 400); return size(); })(), { width: 500, height: 400 });
test('and adds one more raw event', rawEvents, 5);
test('with no extra work until the timer fires', workDone, 1);
test('then exactly one more', (() => { clock.advance(200); return workDone; })(), 2);
Adapted. There is no real window, so window here is the stand-in and resizeTo(w, h) sets the size and fires resize. The timer is injected for the same reason as the debounce exercise in the Basic section: a test cannot wait 150ms and still be a test. The live demo is a real frame — drag its edge, or resize the browser, and both counters move.
The point of the exercise is the debounce. resize fires continuously while a window is being dragged, so doing anything costly per event means dozens of layout recalculations a second. The pattern is to split the work: cheap updates run on every event, and the expensive part is delayed with a timer that gets cancelled and restarted each time, so it runs once after the movement stops. The assertions show four events producing one unit of work.
On the measurements themselves: window.innerWidth includes the scrollbar, document.documentElement.clientWidth does not. If a value is being compared against a CSS media query, use clientWidth — media queries do not count the scrollbar either, and the mismatch is a classic source of an off-by-15-pixels bug.