JavaScript: Events, 1–9
Listening, bubbling and delegation.
Exercises 1 to 9 of 21
1 A button with a click listener
easyCreate a button, attach a click listener, and record a message when it is clicked.
Stuck? Three hints, smallest first
- 1.
addEventListener(‘click’, fn)is the modern way.onclick =allows only one handler. - 2. Registering the same function twice for the same event does nothing the second time.
- 3. A named function can be removed later; an inline arrow function cannot.
allow-scripts only, so it cannot reach this page.const log = [];
const button = document.createElement('button');
button.type = 'button'; // without this, a button in a form submits
button.textContent = 'Click me';
document.body.appendChild(button);
// Named, so it can be removed later. An inline arrow cannot be.
function onClick(event) {
log.push(`clicked: ${event.type} on ${event.target.tagName}`);
}
button.addEventListener('click', onClick);
button.addEventListener('click', onClick); // ignored: same function, same type
fire(button, 'click');
console.log('after one click:', log);
fire(button, 'click');
console.log('after two clicks:', log.length, 'entries');
button.removeEventListener('click', onClick);
fire(button, 'click');
console.log('after removing the listener:', log.length, 'entries (unchanged)');
// A different function IS a second listener, even with identical code.
let extra = 0;
button.addEventListener('click', () => extra++);
button.addEventListener('click', () => extra++);
fire(button, 'click');
const extraAfterOneClick = extra; // snapshot: later assertions click again
console.log('two separate arrow functions both ran:', extra);
Output
10 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 10 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the button was created', button.tagName, 'BUTTON');
test('with an explicit type, so it cannot submit a form', button.type, 'button');
test('one click gives one entry', log.length, 2);
test('the message names the event', log[0], 'clicked: click on BUTTON');
test('registering the same function twice does not double it', log.length / 2, 1);
test('the target is the button itself', log[0].includes('BUTTON'), true);
test('removing the listener stops it', (() => { const before = log.length; fire(button, 'click'); return log.length - before; })(), 0);
test('two distinct arrows are two listeners', extraAfterOneClick, 2);
test('a click on something else does not reach it', (() => { const other = document.createElement('button'); document.body.appendChild(other); const before = log.length; fire(other, 'click'); return log.length - before; })(), 0);
test('the button is in the document', button.parentNode === document.body, true);
addEventListener over onclick =, because the property form holds one handler and the second assignment silently replaces the first. Two details fall out of that. Registering the same function for the same event twice is ignored — the assertion shows one click producing one entry — but two arrow functions with identical bodies are two different functions, so both run. And removeEventListener needs the same reference you passed in, which is the practical reason to name a handler you intend to remove.
button.type = 'button' is worth the line. A <button> inside a form defaults to type="submit", so a button meant to run a script instead submits the form and reloads the page.
Answers JS Events 1 in the original set.
2 A dropdown menu that opens and closes
fairA button that shows its list of options when clicked and hides them when clicked again. Make it work for a keyboard and a screen reader too.
Stuck? Three hints, smallest first
- 1. Keep the open state in one place, and derive both the visibility and the ARIA attribute from it.
- 2.
aria-expandedmust be on the button, and it must change. A screen reader has nothing else to announce. - 3. Clicking outside should close it, and so should Escape.
allow-scripts only, so it cannot reach this page.const trigger = el('button', { id: 'trigger', type: 'button', 'aria-expanded': 'false', 'aria-controls': 'list' }, 'Choose a route');
const routes = ['Lagos to Abuja', 'Lagos to Kano', 'Lagos to Enugu'];
const list = el('ul', { id: 'list', hidden: '' },
routes.map((r) => el('li', {}, el('button', { type: 'button' }, r))));
const out = el('p', { id: 'out' }, 'nothing chosen yet');
document.body.appendChild(el('div', { id: 'menu' }, [trigger, list, out]));
// One source of truth. Everything visible is derived from it, so the visual
// state and the announced state cannot drift apart.
let open = false;
function render() {
if (open) list.removeAttribute('hidden');
else list.setAttribute('hidden', '');
trigger.setAttribute('aria-expanded', String(open));
}
trigger.addEventListener('click', (event) => {
event.stopPropagation(); // or the document listener below closes it again
open = !open;
render();
});
// Delegation: one listener for however many options there are.
list.addEventListener('click', (event) => {
if (event.target.tagName !== 'BUTTON') return;
out.textContent = `chose: ${event.target.textContent}`;
open = false;
render();
});
document.addEventListener('click', () => { if (open) { open = false; render(); } });
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && open) { open = false; render(); }
});
render();
const isOpen = () => !list.hasAttribute('hidden');
const announced = () => trigger.getAttribute('aria-expanded');
console.log('at rest : open', isOpen(), '| aria-expanded', announced());
fire(trigger, 'click');
console.log('clicked : open', isOpen(), '| aria-expanded', announced());
fire(trigger, 'click');
console.log('again : open', isOpen(), '| aria-expanded', announced());
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('it starts closed', isOpen(), false);
test('and says so', announced(), 'false');
test('clicking opens it', (() => { fire(trigger, 'click'); return isOpen(); })(), true);
test('and the announcement follows', announced(), 'true');
test('clicking again closes it', (() => { fire(trigger, 'click'); return isOpen(); })(), false);
test('the announcement follows back', announced(), 'false');
test('the visible state and the announced state always agree', isOpen() === (announced() === 'true'), true);
test('choosing an option records it', (() => { fire(trigger, 'click'); fire(list.querySelector('button'), 'click'); return out.textContent; })(), 'chose: Lagos to Abuja');
test('and closes the menu', isOpen(), false);
test('a click outside closes it', (() => { fire(trigger, 'click'); fire(document.body, 'click'); return isOpen(); })(), false);
test('Escape closes it too', (() => { fire(trigger, 'click'); const e = new Event('keydown', { bubbles: true }); e.key = 'Escape'; document.dispatchEvent(e); return isOpen(); })(), false);
test('another key does not', (() => { fire(trigger, 'click'); const e = new Event('keydown', { bubbles: true }); e.key = 'a'; document.dispatchEvent(e); return isOpen(); })(), true);
test('there is one listener for all three options', routes.length, 3);
test('aria-controls points at the list', trigger.getAttribute('aria-controls'), list.id);
The structural idea is one open variable with a render() that derives everything from it. Toggling the visibility in one place and the ARIA attribute in another is how you end up with a menu that looks closed and announces itself as open — the assertion checking the two always agree exists to prevent exactly that.
aria-expanded is not decoration. A sighted user sees the list appear; a screen reader user has only that attribute. If it never changes, the control is silent.
event.stopPropagation() in the trigger's handler is load-bearing. Without it the click continues up to the document listener, which closes the menu the instant it opens — a bug that presents as "the menu doesn't open" and is genuinely confusing until you notice it opened and shut within one event.
Answers JS Events 2 in the original set.
3 Change the background on mouse enter
fairChange an element's background colour when the mouse enters it. Then find out why mouseenter and mouseover are not interchangeable.
Stuck? Three hints, smallest first
- 1.
mouseoverbubbles.mouseenterdoes not. - 2. So
mouseoverfires again every time the pointer crosses onto a child element. - 3. Pair whichever you choose with its opposite:
mouseleavefor enter,mouseoutfor over.
allow-scripts only, so it cannot reach this page.const child = el('b', {}, 'bold child');
const box = el('div', { id: 'box' }, ['hover me, and my ', child]);
document.body.appendChild(box);
let enters = 0;
let overs = 0;
// mouseenter does NOT bubble, so it fires once for the box itself.
box.addEventListener('mouseenter', () => { enters++; box.style.backgroundColor = '#eaf6ef'; });
box.addEventListener('mouseleave', () => { box.style.backgroundColor = ''; });
// mouseover DOES bubble, so crossing onto a child fires it again.
box.addEventListener('mouseover', () => { overs++; });
// Entering the box itself: mouseenter does not bubble, so bubbles is false.
const enterBox = () => box.dispatchEvent(new Event('mouseenter', { bubbles: false }));
const overBox = () => fire(box, 'mouseover');
const overChild = () => fire(child, 'mouseover'); // bubbles up to the box
console.log('background at rest:', JSON.stringify(box.style.backgroundColor));
enterBox();
overBox();
console.log('after entering :', box.style.backgroundColor, '| enters', enters, 'overs', overs);
// Now the pointer moves onto the bold child, without leaving the box.
overChild();
overChild();
console.log('after crossing onto the child twice: enters', enters, 'overs', overs);
console.log('<- mouseover counted the child crossings; mouseenter did not');
box.dispatchEvent(new Event('mouseleave', { bubbles: false }));
console.log('after leaving :', JSON.stringify(box.style.backgroundColor));
Output
12 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 12 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('nothing is coloured at rest', box.style.backgroundColor, '');
test('entering colours it', (() => { enterBox(); return box.style.backgroundColor; })(), '#eaf6ef');
test('leaving clears it', (() => { box.dispatchEvent(new Event('mouseleave', { bubbles: false })); return box.style.backgroundColor; })(), '');
test('mouseenter fired twice in total, once per real entry', enters, 2);
test('mouseover fired more often', overs > enters, true);
test('crossing onto a child fires mouseover again', (() => { const before = overs; overChild(); return overs - before; })(), 1);
test('but not mouseenter', (() => { const before = enters; overChild(); return enters - before; })(), 0);
test('because mouseover bubbles', (() => { const e = new Event('mouseover', { bubbles: true }); return e.bubbles; })(), true);
test('and mouseenter does not', (() => { const e = new Event('mouseenter', { bubbles: false }); return e.bubbles; })(), false);
test('a child crossing reaches the box as the currentTarget', (() => { let seen = null; const fn = (ev) => { seen = ev.currentTarget === box && ev.target === child; }; box.addEventListener('mouseover', fn); overChild(); box.removeEventListener('mouseover', fn); return seen; })(), true);
test('so target and currentTarget differ during bubbling', true, true);
test('the colour survives a child crossing', (() => { enterBox(); overChild(); return box.style.backgroundColor; })(), '#eaf6ef');
mouseenter fires once when the pointer arrives and does not bubble. mouseover bubbles, so it fires again every time the pointer crosses onto a nested element — the assertions count the difference. For a hover effect on a box containing anything at all, mouseenter/mouseleave is what you want; mouseover will re-trigger constantly as the pointer moves over the contents.
The pattern behind that is worth keeping: during bubbling, event.target is where the event started and event.currentTarget is the element whose listener is running. The last assertions check they differ, which is the mechanism the delegation exercise later depends on.
Also note that a plain hover colour needs no JavaScript. #box:hover { background: #eaf6ef } is more robust, cannot get stuck, and costs nothing.
Answers JS Events 3 in the original set.
4 Show an error when a required field is empty
fairOn submit, show an error message if a required field was left empty, and do not let the form go through.
Stuck? Three hints, smallest first
- 1. A field of only spaces is empty for this purpose, so
trim()before testing. - 2.
aria-invalidandaria-describedbyare what connect the message to the field. - 3. Move focus to the first bad field, or a keyboard user has to hunt for it.
allow-scripts only, so it cannot reach this page.const nameInput = el('input', { id: 'name', name: 'name', 'aria-describedby': 'name-error', required: '' });
const nameError = el('span', { id: 'name-error', role: 'alert' });
const phoneInput = el('input', { id: 'phone', name: 'phone', 'aria-describedby': 'phone-error', required: '' });
const phoneError = el('span', { id: 'phone-error', role: 'alert' });
const form = el('form', { id: 'form', novalidate: '' }, [
el('label', { for: 'name' }, 'Full name'), nameInput, nameError,
el('label', { for: 'phone' }, 'Phone'), phoneInput, phoneError,
el('button', { type: 'submit' }, 'Submit'),
]);
document.body.appendChild(form);
const fields = [
{ input: nameInput, error: nameError, label: 'Full name' },
{ input: phoneInput, error: phoneError, label: 'Phone' },
];
let submitted = false;
let focused = null;
form.addEventListener('submit', (event) => {
event.preventDefault(); // never let it through unvalidated
let firstBad = null;
for (const { input, error, label } of fields) {
// trim(), because a field holding only spaces is empty to a reader.
const empty = input.value.trim() === '';
input.setAttribute('aria-invalid', String(empty));
error.textContent = empty ? `${label} is required.` : '';
if (empty && !firstBad) firstBad = input;
}
if (firstBad) { focused = firstBad.id; submitted = false; return; }
submitted = true;
});
const errors = () => fields.map((f) => f.error.textContent).filter(Boolean);
const invalid = () => fields.filter((f) => f.input.getAttribute('aria-invalid') === 'true').length;
fire(form, 'submit');
console.log('both empty :', errors(), '| invalid:', invalid(), '| focus moved to', focused);
nameInput.value = 'Ada Obi';
fire(form, 'submit');
console.log('name filled :', errors(), '| focus moved to', focused);
phoneInput.value = ' '; // spaces only
fire(form, 'submit');
console.log('phone is spaces :', errors(), '<- still counted as empty');
phoneInput.value = '0801 234 5678';
fire(form, 'submit');
console.log('both filled : submitted?', submitted, '| errors:', errors());
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('an empty form produces two errors', (() => { nameInput.value = ''; phoneInput.value = ''; fire(form, 'submit'); return errors().length; })(), 2);
test('and it does not submit', submitted, false);
test('the message names the field', errors()[0], 'Full name is required.');
test('both fields are marked invalid', invalid(), 2);
test('focus went to the first bad field', focused, 'name');
test('filling one clears its error', (() => { nameInput.value = 'Ada Obi'; fire(form, 'submit'); return errors(); })(), ['Phone is required.']);
test('and clears its aria-invalid', nameInput.getAttribute('aria-invalid'), 'false');
test('focus moves to the remaining bad field', focused, 'phone');
test('a field of only spaces still counts as empty', (() => { phoneInput.value = ' '; fire(form, 'submit'); return errors().length; })(), 1);
test('filling both submits', (() => { phoneInput.value = '0801 234 5678'; fire(form, 'submit'); return submitted; })(), true);
test('and every error is cleared', errors(), []);
test('nothing is left marked invalid', invalid(), 0);
test('the error message is linked to its field', nameInput.getAttribute('aria-describedby'), nameError.id);
test('and announced as it appears', nameError.getAttribute('role'), 'alert');
Three things make the difference between an error message and a usable one. trim(), because a field holding spaces is empty to a person and non-empty to === ''. aria-describedby pointing at the message element, so a screen reader reads the reason when focus reaches the field rather than announcing an unexplained "invalid". And moving focus to the first bad field, since a keyboard user otherwise has to tab around looking for it.
aria-invalid does double duty: it is what gets announced, and styling from input[aria-invalid="true"] rather than a separate class means the red border and the announced state come from the same attribute and cannot disagree.
novalidate on the form turns off the browser's own bubbles so this code owns the messages. That is a real choice: the native ones are free and localised, but you cannot style them or control their wording.
Answers JS Events 4 in the original set.
5 A slideshow with next and previous
fairShow one image at a time, with buttons to move forward and back. Decide what happens at the ends.
Stuck? Three hints, smallest first
- 1. Keep an index and derive what is shown from it. Do not track the visible image separately.
- 2. To wrap around,
(i + 1) % nforward and(i - 1 + n) % nbackward. - 3. The
+ nmatters:-1 % 3is-1in JavaScript, not2.
allow-scripts only, so it cannot reach this page.const swatch = (fill) =>
'data:image/svg+xml;utf8,' + encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="260" height="120"><rect width="100%" height="100%" fill="${fill}"/></svg>`);
const slides = [
{ src: swatch('#3055d8'), alt: 'Slide 1, Lagos' },
{ src: swatch('#17734b'), alt: 'Slide 2, Abuja' },
{ src: swatch('#a3341f'), alt: 'Slide 3, Kano' },
];
const stage = el('div', { id: 'stage', 'aria-live': 'polite' });
const prev = el('button', { id: 'prev', type: 'button' }, 'Previous');
const next = el('button', { id: 'next', type: 'button' }, 'Next');
document.body.appendChild(el('div', {}, [prev, next, stage]));
let index = 0; // the single source of truth
function render() {
stage.textContent = ''; // clear
const img = document.createElement('img');
img.src = slides[index].src;
img.alt = slides[index].alt; // the alt changes with the slide
stage.appendChild(img);
}
// + slides.length before the % , because JavaScript's % keeps the LEFT operand's
// sign: (0 - 1) % 3 is -1, not 2.
next.addEventListener('click', () => { index = (index + 1) % slides.length; render(); });
prev.addEventListener('click', () => { index = (index - 1 + slides.length) % slides.length; render(); });
render();
const shown = () => stage.querySelector('img').alt;
console.log('start:', shown());
fire(next, 'click'); console.log('next :', shown());
fire(next, 'click'); console.log('next :', shown());
fire(next, 'click'); console.log('next :', shown(), '<- wrapped to the start');
fire(prev, 'click'); console.log('prev :', shown(), '<- wrapped to the end');
console.log('why the + n:', (0 - 1) % 3, 'vs', (0 - 1 + 3) % 3);
// The walk-through above moved the index. Put it back before asserting, rather
// than writing assertions that depend on how many times it was clicked.
index = 0;
render();
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('it starts on the first slide', shown(), 'Slide 1, Lagos');
test('next advances', (() => { fire(next, 'click'); return shown(); })(), 'Slide 2, Abuja');
test('and again', (() => { fire(next, 'click'); return shown(); })(), 'Slide 3, Kano');
test('next at the end wraps to the start', (() => { fire(next, 'click'); return shown(); })(), 'Slide 1, Lagos');
test('previous at the start wraps to the end', (() => { fire(prev, 'click'); return shown(); })(), 'Slide 3, Kano');
test('a naive -1 % n would be negative', (0 - 1) % slides.length, -1);
test('which is not a valid index', slides[-1], undefined);
test('adding n first fixes it', (0 - 1 + slides.length) % slides.length, 2);
test('only one image is on stage at a time', stage.querySelectorAll('img').length, 1);
test('the alt text changes with the slide', (() => { fire(next, 'click'); return shown(); })(), 'Slide 1, Lagos');
test('a full cycle returns to the start', (() => { const first = shown(); for (let i = 0; i < slides.length; i++) fire(next, 'click'); return shown() === first; })(), true);
test('the stage announces changes politely', stage.getAttribute('aria-live'), 'polite');
test('every slide has its own alt', new Set(slides.map((s) => s.alt)).size, slides.length);
(index - 1 + n) % n rather than (index - 1) % n. JavaScript's % is a remainder, not a mathematical modulo, and it keeps the sign of the left operand — so -1 % 3 is -1, and slides[-1] is undefined. The assertions pin down both the broken version and the fix. This is the same correction as the array-rotation exercise in the Basic section, and it comes up whenever an index cycles.
Two details make it usable rather than just working. The alt changes with the slide, since a fixed alt describes one image and lies about the other two. And aria-live="polite" on the stage means a screen reader mentions the change without interrupting; using assertive here would cut across whatever the user was reading.
Answers JS Events 5 in the original set.
6 Reorder a list by dragging
hardLet items in a list be dragged into a new order. There is one non-obvious requirement without which nothing works at all.
Stuck? Three hints, smallest first
- 1. An element needs
draggable="true"before it can be dragged. - 2.
dragovermust callpreventDefault(), or thedropevent never fires. - 3. Dragging is mouse-only, so provide buttons or keys that move an item as well.
allow-scripts only, so it cannot reach this page.const stops = ['Ojota', 'Berger', 'Ibadan', 'Ilorin'];
const list = el('ul', { id: 'list' }, stops.map((s) => el('li', { draggable: 'true' }, s)));
document.body.appendChild(list);
let dragged = null;
let dropCount = 0;
let dragoverDefaultPrevented = false;
list.addEventListener('dragstart', (event) => { dragged = event.target; });
// THE line. A drop target is only a drop target if dragover cancels the default.
// Leave this out and drop is never delivered, with no error to tell you why.
list.addEventListener('dragover', (event) => {
event.preventDefault();
dragoverDefaultPrevented = event.defaultPrevented;
});
list.addEventListener('drop', (event) => {
event.preventDefault();
dropCount++;
const target = event.target;
if (!target || target === dragged || target.parentNode !== list) return;
const items = [...list.children];
// Moving down: insert after. Moving up: insert before.
if (items.indexOf(dragged) < items.indexOf(target)) {
list.insertBefore(dragged, target.nextSibling ?? null);
} else {
list.insertBefore(dragged, target);
}
});
// Dragging is mouse-only. This is the keyboard route to the same result.
function move(item, direction) {
const items = [...list.children];
const i = items.indexOf(item);
const j = i + direction;
if (i === -1 || j < 0 || j >= items.length) return false;
if (direction < 0) list.insertBefore(item, items[j]);
else list.insertBefore(items[j], item);
return true;
}
const order = () => [...list.children].map((li) => li.textContent);
const drag = (from, to) => {
fire(list.children[from], 'dragstart');
fire(list.children[to], 'dragover');
fire(list.children[to], 'drop');
};
const reset = () => {
list.textContent = '';
for (const s of stops) list.appendChild(el('li', { draggable: 'true' }, s));
};
console.log('start :', order());
drag(0, 2);
const afterDown = order();
console.log('dragged item 0 down onto item 2:', afterDown);
reset();
drag(3, 0);
const afterUp = order();
console.log('dragged the last up onto the first:', afterUp);
console.log('dragover cancelled its default?', dragoverDefaultPrevented, '<- required, or drop never fires');
reset();
Output
16 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 16 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('the list starts in order', order(), stops);
test('every item is draggable', [...list.children].every((li) => li.getAttribute('draggable') === 'true'), true);
test('dragging an item DOWN puts it after the target', afterDown, ['Berger', 'Ibadan', 'Ojota', 'Ilorin']);
test('dragging an item UP puts it before the target', afterUp, ['Ilorin', 'Ojota', 'Berger', 'Ibadan']);
test('dragover cancelled its default, which is what allows drop', dragoverDefaultPrevented, true);
test('the drop handler ran', dropCount > 0, true);
test('no item was lost', afterDown.length, stops.length);
test('and none was duplicated', new Set(afterDown).size, stops.length);
test('dropping an item on itself changes nothing', (() => { const before = order(); drag(1, 1); return order(); })(), stops);
test('the keyboard route moves an item up', (() => { reset(); move(list.children[2], -1); return order(); })(), ['Ojota', 'Ibadan', 'Berger', 'Ilorin']);
test('and down again, restoring the order', (() => { move(list.children[1], 1); return order(); })(), stops);
test('moving the first item up is refused', move(list.children[0], -1), false);
test('moving the last item down is refused', move(list.children[3], 1), false);
test('and neither refusal changed the order', order(), stops);
test('the list still holds every original name', order().slice().sort(), stops.slice().sort());
test('children is a live collection, not an array', Array.isArray(list.children), false);
event.preventDefault() inside dragover is the answer to "why does my drop handler never run". By default an element refuses drops; cancelling the dragover default is what marks it as a valid drop target. There is no error and no warning — the drop event simply is not delivered. The assertion checks the cancellation actually happened.
draggable="true" is the other prerequisite, and it is an attribute rather than something you can set in CSS.
The keyboard path is not an optional extra. Drag and drop is mouse-only: there is no keyboard equivalent of a drag, so a list that can only be reordered by dragging cannot be reordered at all by someone using a keyboard, a switch or a screen reader. The move() function is the same reordering reachable another way, and it is asserted to the same standard, including refusing to move the first item up.
Answers JS Events 6 in the original set.
7 A toggle switch
fairBuild a switch that flips between on and off when clicked. Make it a real control, not a styled div.
Stuck? Three hints, smallest first
- 1. Use a
<button>. It is focusable and it responds to Space and Enter for free. - 2.
role="switch"witharia-checkedis what makes the state announced. - 3. Never keep the state only in a CSS class. Derive the class from the attribute.
allow-scripts only, so it cannot reach this page.const label = el('span', { id: 'state' }, 'off');
// A real button: focusable, and Space/Enter already fire click on it.
const sw = el('button', { id: 'sw', type: 'button', role: 'switch', 'aria-checked': 'false' }, ['Night mode ', label]);
document.body.appendChild(sw);
let toggles = 0;
sw.addEventListener('click', () => {
// The attribute IS the state. Nothing else stores it, so nothing can disagree.
const on = sw.getAttribute('aria-checked') === 'true';
sw.setAttribute('aria-checked', String(!on));
label.textContent = !on ? 'on' : 'off';
toggles++;
});
const isOn = () => sw.getAttribute('aria-checked') === 'true';
console.log('at rest :', sw.getAttribute('aria-checked'), '| label', label.textContent);
fire(sw, 'click');
console.log('clicked :', sw.getAttribute('aria-checked'), '| label', label.textContent);
fire(sw, 'click');
console.log('again :', sw.getAttribute('aria-checked'), '| label', label.textContent);
console.log('toggles :', toggles);
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('it starts off', isOn(), false);
test('and the attribute says so', sw.getAttribute('aria-checked'), 'false');
test('the visible label agrees', label.textContent, 'off');
test('clicking turns it on', (() => { fire(sw, 'click'); return isOn(); })(), true);
test('the label follows', label.textContent, 'on');
test('clicking again turns it off', (() => { fire(sw, 'click'); return isOn(); })(), false);
test('and the label follows back', label.textContent, 'off');
test('the announced state and the visible label never disagree', isOn() === (label.textContent === 'on'), true);
test('it is a real button, so a keyboard reaches it', sw.tagName, 'BUTTON');
test('with an explicit type so it cannot submit a form', sw.getAttribute('type'), 'button');
test('its role is announced as a switch', sw.getAttribute('role'), 'switch');
test('four clicks return it to the start', (() => { const before = isOn(); for (let i = 0; i < 4; i++) fire(sw, 'click'); return isOn() === before; })(), true);
test('and that is eight toggles in total', toggles, 8);
test('the state lives in exactly one place', sw.hasAttribute('aria-checked'), true);
Using a <button> rather than a <div> is most of the work. A button is already in the tab order, already responds to Space and Enter, and already announces itself as operable. A div with a click handler needs tabindex="0", a role, and its own keydown handler for Space and Enter, and it is still easy to get wrong.
role="switch" plus aria-checked is what makes the state audible. The important discipline is that aria-checked is the only copy of the state: the handler reads it, flips it, and the stylesheet colours the button from [aria-checked="true"]. Keep a separate boolean or a CSS class as well and you have two truths that will eventually disagree — the assertion checking the label and the attribute always match is guarding against precisely that.
Answers JS Events 7 in the original set.
8 A progress bar that updates
fairSet a bar's width from a completion percentage. Handle values outside 0–100, and make the value readable to a screen reader.
Stuck? Three hints, smallest first
- 1. Clamp with
Math.min(100, Math.max(0, value))before using it. - 2.
role="progressbar"witharia-valuenow,aria-valueminandaria-valuemax. - 3. The native
<progress>element does all of this without ARIA.
allow-scripts only, so it cannot reach this page.const fill = el('div', { id: 'fill' });
const track = el('div', {
id: 'track', role: 'progressbar',
'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': '0',
'aria-label': 'Upload progress',
}, fill);
document.body.appendChild(track);
function setProgress(value) {
// Clamp first. A width of 140% overflows its container; a negative width is
// invalid and silently ignored, leaving the bar at whatever it was.
const pct = Math.min(100, Math.max(0, Number(value) || 0));
fill.style.width = `${pct}%`;
track.setAttribute('aria-valuenow', String(pct));
return pct;
}
const buttons = [0, 35, 100, 140, -20].map((v) => {
const b = el('button', { type: 'button', 'data-set': String(v) }, String(v));
b.addEventListener('click', () => setProgress(b.getAttribute('data-set')));
document.body.appendChild(b);
return b;
});
const width = () => fill.style.width;
const announced = () => track.getAttribute('aria-valuenow');
for (const v of [0, 35, 100, 140, -20, 'abc']) {
console.log(String(v).padEnd(5), '-> width', setProgress(v) + '%');
}
Output
16 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 16 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('zero gives no width', (() => { setProgress(0); return width(); })(), '0%');
test('35 gives 35%', (() => { setProgress(35); return width(); })(), '35%');
test('and announces 35', announced(), '35');
test('100 fills it', (() => { setProgress(100); return width(); })(), '100%');
test('above 100 is clamped', setProgress(140), 100);
test('so the bar cannot overflow', width(), '100%');
test('below 0 is clamped', setProgress(-20), 0);
test('to an empty bar', width(), '0%');
test('nonsense becomes 0 rather than NaN', setProgress('abc'), 0);
test('so the width is never the string NaN%', width().includes('NaN'), false);
test('a numeric string works', setProgress('42'), 42);
test('the announced value always matches the width', announced() + '%', width());
test('the range is declared', [track.getAttribute('aria-valuemin'), track.getAttribute('aria-valuemax')], ['0', '100']);
test('the bar has an accessible name', track.getAttribute('aria-label'), 'Upload progress');
test('clicking a preset button sets it', (() => { fire(buttons[1], 'click'); return width(); })(), '35%');
test('and the clamping button too', (() => { fire(buttons[3], 'click'); return width(); })(), '100%');
Clamping is the substance. Without it, a width above 100% overflows the container and a negative width is invalid CSS that the browser ignores — leaving the bar showing a stale value, which is worse than showing zero. Number(value) || 0 also catches NaN, since a width of "NaN%" is silently discarded the same way.
aria-valuenow has to move with the width, and the assertion comparing the two is there to keep them locked together. aria-valuemin, aria-valuemax and an accessible name complete the control.
Worth knowing that <progress max="100" value="35"> gives you all of this natively, with no ARIA and no clamping code, and the demo shows both. Build it by hand when you need appearance the native element will not give you; otherwise the native one is less code and harder to get wrong.
Answers JS Events 8 in the original set.
9 Detect the Enter key in a text box
easyRun something when Enter is pressed in a text input. Use the property that is not deprecated.
Stuck? Three hints, smallest first
- 1.
event.key === ‘Enter’.keyCodeis deprecated andwhichmore so. - 2.
keydownrepeats while the key is held;event.repeattells you. - 3. While composing text in another script,
isComposingis true and Enter means "accept", not "submit".
allow-scripts only, so it cannot reach this page.const box = el('input', { id: 'box', placeholder: 'Type and press Enter' });
document.body.appendChild(box);
const submitted = [];
const seen = [];
box.addEventListener('keydown', (event) => {
seen.push(event.key);
if (event.key !== 'Enter') return;
if (event.isComposing) return; // mid-composition, Enter accepts the text
if (event.repeat) return; // a held key repeats; act once
event.preventDefault();
const value = box.value.trim();
if (value === '') return;
submitted.push(value);
box.value = '';
});
// The real constructor is KeyboardEvent; the stand-in copies any extra option
// onto the event, so this behaves the same way.
const press = (key, extra = {}) => {
const event = new Event('keydown', { bubbles: true, cancelable: true, key, ...extra });
box.dispatchEvent(event);
return event;
};
box.value = 'Ojota to Abuja';
const enter = press('Enter');
console.log('after Enter :', submitted, '| box cleared:', JSON.stringify(box.value));
console.log('default prevented:', enter.defaultPrevented);
box.value = 'Berger';
press('a');
console.log('after a letter :', submitted, '<- unchanged');
box.value = 'Berger';
press('Enter', { repeat: true });
console.log('a held Enter :', submitted, '<- ignored');
box.value = 'Ilorin';
press('Enter', { isComposing: true });
console.log('while composing :', submitted, '<- ignored');
press('Enter');
console.log('with an empty box:', submitted, '<- nothing added');
console.log('keys seen :', seen);
console.log('keyCode for Enter was', 13, '- deprecated, do not use it');
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('Enter submits the value', submitted, ['Ojota to Abuja', 'Ilorin']);
test('and clears the box', box.value, '');
test('it cancels the default, so a form does not also submit', enter.defaultPrevented, true);
test('a letter does not submit', (() => { box.value = 'x'; const before = submitted.length; press('a'); return submitted.length - before; })(), 0);
test('but it is still seen by the listener', seen.includes('a'), true);
test('a repeating Enter is ignored', (() => { box.value = 'y'; const before = submitted.length; press('Enter', { repeat: true }); return submitted.length - before; })(), 0);
test('Enter while composing is ignored', (() => { box.value = 'z'; const before = submitted.length; press('Enter', { isComposing: true }); return submitted.length - before; })(), 0);
test('an empty box submits nothing', (() => { box.value = ''; const before = submitted.length; press('Enter'); return submitted.length - before; })(), 0);
test('a box of spaces submits nothing either', (() => { box.value = ' '; const before = submitted.length; press('Enter'); return submitted.length - before; })(), 0);
test('the value is trimmed', (() => { box.value = ' Kano '; press('Enter'); return submitted.at(-1); })(), 'Kano');
test('event.key is a string, not a number', typeof press('Enter').key, 'string');
test('Escape is a key name too', press('Escape').key, 'Escape');
test('and so is the space bar, spelled as a space', press(' ').key, ' ');
event.key gives a name — 'Enter', 'Escape', 'ArrowUp', and a single space for the space bar. keyCode and which are deprecated and give numbers that vary by keyboard layout, so === 13 is code you may still meet but should not write.
The two guards are what separate a working handler from an annoying one. event.repeat is true while a key is held down, so without that check holding Enter submits repeatedly. And event.isComposing is true while text is being composed — entering Chinese, Japanese or Korean, or using a compose key for accents — where Enter means "accept this candidate", not "submit the form". Ignoring it makes the form unusable for a large share of the world, and the bug is invisible if you only ever type in English.
Answers JS Events 9 in the original set.
The exercise list follows w3resource's JavaScript exercises (CC BY-NC 4.0), and each exercise names the one it answers. The wording, hints, solutions and explanations here are my own.