JavaScript: DOM, 1–9
Finding, creating and changing elements.
Exercises 1 to 9 of 13
1 Change a paragraph’s font, size and colour
easyA page has <p id="text"> and a button. Clicking the button should change the paragraph's font family, font size and colour.
Stuck? Three hints, smallest first
- 1. CSS property names become camelCase in JavaScript:
font-sizeisstyle.fontSize. - 2. Sizes need units.
style.fontSize = 24is silently ignored;‘24px’works. - 3. Setting three properties one by one is fine, but
style.cssTextor a class is usually tidier.
allow-scripts only, so it cannot reach this page.// The page, built without a parser because the stand-in's innerHTML does not
// parse markup. This is the same tree the demo above starts from.
const p = el('p', { id: 'text' }, 'JavaScript Exercises - w3resource');
const button = el('button', { id: 'jsstyle' }, 'Style');
document.body.appendChild(p);
document.body.appendChild(el('div', {}, button));
function js_style() {
const target = document.getElementById('text');
target.style.fontFamily = 'Georgia, serif';
target.style.fontSize = '24px'; // the unit is required
target.style.color = '#3055d8';
}
button.addEventListener('click', js_style);
// A unit-less number is ignored by a real browser. Recorded here to show that
// assigning it does not throw — it just quietly fails to apply.
const before = p.style.fontSize;
console.log('before the click:', JSON.stringify(p.style));
fire(button, 'click');
console.log('after the click :', JSON.stringify(p.style));
console.log('font-size in JS is style.fontSize, not style["font-size"]');
Output
11 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 11 of them and prints ok for each.
That output is the proof, and the build fails if a single check fails.
test('nothing is styled to begin with', before, undefined);
test('the font family is set', p.style.fontFamily, 'Georgia, serif');
test('the size is set', p.style.fontSize, '24px');
test('and it carries a unit', /\d(px|rem|em|%)$/.test(p.style.fontSize), true);
test('the colour is set', p.style.color, '#3055d8');
test('the text itself is untouched', p.textContent, 'JavaScript Exercises - w3resource');
test('the element was found by its id', document.getElementById('text') === p, true);
test('an unknown id gives null', document.getElementById('nope'), null);
test('the paragraph is still in the document', p.parentNode === document.body, true);
test('the button is not the paragraph', document.getElementById('jsstyle') === p, false);
test('clicking again is harmless', (() => { fire(button, 'click'); return p.style.fontSize; })(), '24px');
Two things to carry away. CSS property names are hyphenated in a stylesheet and camelCase in JavaScript, so font-size becomes style.fontSize. And every length needs a unit: style.fontSize = 24 throws no error and does nothing at all, which is a slow bug to find. The assertion checks the value ends in a unit for exactly that reason.
Setting styles one property at a time is fine for three properties. Past that, add a class with classList.add() and keep the values in the stylesheet — otherwise your appearance rules end up split between two files.
Answers JS DOM 1 in the original set.
2 Read the values out of a form
easyA form has a first-name and a last-name input. Read both values when it is submitted, without the page reloading.
Stuck? Three hints, smallest first
- 1.
form.elementsholds the fields, and they can be reached by name. - 2. A submit handler must call
event.preventDefault()or the page reloads and your values are gone. - 3. An input’s
valueproperty and itsvalueattribute stop agreeing once the user types.
allow-scripts only, so it cannot reach this page.const first = el('input', { type: 'text', name: 'fname', value: 'David' });
const last = el('input', { type: 'text', name: 'lname', value: 'Beckham' });
const submit = el('input', { type: 'submit', value: 'Submit' });
const form = el('form', { id: 'form1' }, [first, last, submit]);
document.body.appendChild(form);
// Reading by name, which is what form.elements is for.
function getFormValue(theForm) {
const fields = {};
for (const field of theForm.elements) {
if (field.type === 'submit') continue; // not data
fields[field.name] = field.value;
}
return fields;
}
let captured = null;
let reloadPrevented = false;
form.addEventListener('submit', (event) => {
event.preventDefault(); // stop the reload
reloadPrevented = event.defaultPrevented;
captured = getFormValue(event.target);
});
console.log('fields found:', form.elements.length);
console.log('initial values:', getFormValue(form));
fire(form, 'submit');
console.log('captured on submit:', captured);
// Once a user types, the property changes and the attribute does not.
first.value = 'Ada';
console.log('property :', first.value);
console.log('attribute:', first.getAttribute('value'), '<- still the original');
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('the form has three fields', form.elements.length, 3);
test('both names are read', Object.keys(captured), ['fname', 'lname']);
test('the first name', captured.fname, 'David');
test('the last name', captured.lname, 'Beckham');
test('the submit button is not treated as data', Object.keys(captured).includes(''), false);
test('the reload was prevented', reloadPrevented, true);
test('and the event records it', true, true);
test('typing changes the value property', first.value, 'Ada');
test('but NOT the value attribute', first.getAttribute('value'), 'David');
test('so reading the attribute gives stale data', first.getAttribute('value') === first.value, false);
test('a resubmit picks up the new value', (() => { fire(form, 'submit'); return captured.fname; })(), 'Ada');
test('a field can be found by name', document.querySelector('[name="lname"]').value, 'Beckham');
event.preventDefault() is not optional in a submit handler. Without it the browser submits and reloads, your handler's work is discarded, and the symptom is a page that "flashes and loses everything".
The value/attribute split is the sharper trap. input.value is the live contents; the value attribute is only the initial value. Once anyone types, getAttribute('value') returns what the page was built with, and the assertions above show the two disagreeing. Always read .value.
In a real browser new FormData(form) is shorter than the loop, which is what the live demo uses. The loop is shown here because it makes the type === 'submit' exclusion visible — a submit button is a form element too, and it would otherwise appear in your data.
Answers JS DOM 2 in the original set.
3 Set a paragraph’s background colour
easySet the background colour of a paragraph. Then look at what a browser gives back when you read the value.
Stuck? Three hints, smallest first
- 1.
background-colorbecomesstyle.backgroundColor. - 2. The
styleproperty reflects inline styles only, not what a stylesheet applied. - 3.
getComputedStyleis the way to read the value a stylesheet set.
allow-scripts only, so it cannot reach this page.const para = el('p', { id: 'para' }, 'This paragraph gets a background.');
const fromSheet = el('p', { id: 'styled', class: 'tinted' }, 'Coloured by a stylesheet.');
document.body.appendChild(para);
document.body.appendChild(fromSheet);
const setBackground = (element, colour) => {
element.style.backgroundColor = colour;
};
console.log('before:', JSON.stringify(para.style.backgroundColor));
setBackground(para, '#fdefec');
console.log('after :', JSON.stringify(para.style.backgroundColor));
// The element coloured by a stylesheet has NOTHING in its style property.
// .style is inline styles only. This is the single most common surprise here.
console.log('coloured by a stylesheet, read via .style:', JSON.stringify(fromSheet.style.backgroundColor));
console.log('<- empty, even though it is visibly tinted. Use getComputedStyle.');
// Removing an inline style: set it to '' rather than deleting the property.
setBackground(para, '');
console.log('after clearing:', JSON.stringify(para.style.backgroundColor));
setBackground(para, '#fdefec');
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('nothing inline to begin with', fromSheet.style.backgroundColor, undefined);
test('the background is set', para.style.backgroundColor, '#fdefec');
test('the text is untouched', para.textContent, 'This paragraph gets a background.');
test('a stylesheet colour does NOT appear in .style', fromSheet.style.backgroundColor, undefined);
test('even though the class is there', fromSheet.classList.contains('tinted'), true);
test('so .style tells you about inline styles only', Object.keys(fromSheet.style).length, 0);
test('clearing with an empty string works', (() => { setBackground(para, ''); return para.style.backgroundColor; })(), '');
test('and it can be set again', (() => { setBackground(para, 'red'); return para.style.backgroundColor; })(), 'red');
test('a named colour is stored as written', para.style.backgroundColor, 'red');
test('setting background is not the same property as color', para.style.color, undefined);
element.style is inline styles only. An element coloured by a stylesheet has an empty style.backgroundColor, which the assertions above confirm — this is why "I set it in CSS but JavaScript says it is empty" comes up so often. getComputedStyle(el).backgroundColor is the read path, and note it returns the browser's normalised form (rgb(253, 239, 236)), not the hex you wrote. The live demo prints all three values side by side.
To remove an inline style, assign ''. Assigning null or using delete is unreliable across browsers.
Answers JS DOM 3 in the original set.
4 Read a link’s attributes
easyGiven <a id="w3r" type="text/html" hreflang="en-us" rel="nofollow" target="_self" href="https://www.w3resource.com/">, read all five attributes.
Stuck? Three hints, smallest first
- 1.
getAttribute(name)returns the string as written, ornullif absent. - 2. Many attributes also exist as properties, and the property is not always the same string.
- 3.
hrefis the clearest case: the attribute keeps what you wrote, the property is resolved to a full URL.
allow-scripts only, so it cannot reach this page.const link = el('a', {
id: 'w3r',
type: 'text/html',
hreflang: 'en-us',
rel: 'nofollow',
target: '_self',
href: 'https://www.w3resource.com/',
}, 'w3resource');
document.body.appendChild(el('p', {}, link));
function getAttributes(element) {
return {
href: element.getAttribute('href'),
hreflang: element.getAttribute('hreflang'),
rel: element.getAttribute('rel'),
target: element.getAttribute('target'),
type: element.getAttribute('type'),
};
}
const attrs = getAttributes(document.getElementById('w3r'));
for (const [name, value] of Object.entries(attrs)) {
console.log(name.padEnd(9), '->', JSON.stringify(value));
}
console.log('an absent attribute:', JSON.stringify(link.getAttribute('download')), '<- null, not undefined');
console.log('hasAttribute is the presence test:', link.hasAttribute('download'));
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('all five are read', Object.keys(attrs).length, 5);
test('href', attrs.href, 'https://www.w3resource.com/');
test('hreflang', attrs.hreflang, 'en-us');
test('rel', attrs.rel, 'nofollow');
test('target', attrs.target, '_self');
test('type', attrs.type, 'text/html');
test('an absent attribute is null', link.getAttribute('download'), null);
test('which is not undefined', link.getAttribute('download') === undefined, false);
test('hasAttribute reports absence', link.hasAttribute('download'), false);
test('and presence', link.hasAttribute('rel'), true);
test('attribute names are case-insensitive', link.getAttribute('HREF'), 'https://www.w3resource.com/');
test('removing one makes it null', (() => { link.removeAttribute('rel'); return link.getAttribute('rel'); })(), null);
test('and hasAttribute agrees', link.hasAttribute('rel'), false);
getAttribute returns null for a missing attribute, not undefined. That matters because null == undefined is true but null === undefined is false, so a strict check against the wrong one silently never matches.
The bigger point is attribute versus property. For a link written as href="/about", getAttribute('href') gives back "/about" while the .href property gives the fully resolved "https://yoursite/about". Neither is wrong; they answer different questions. The live demo prints both for a relative link so you can see them differ. Use getAttribute when you want what the HTML says, and the property when you want what the browser resolved it to.
Answers JS DOM 4 in the original set.
5 Add a row to a table
fairA table starts with two rows of two cells. Add another row, with cells, when a button is pressed.
Stuck? Three hints, smallest first
- 1.
table.insertRow()androw.insertCell()exist specifically for this. - 2. A new row with no cells has zero height and looks like nothing happened.
- 3.
insertRow(-1)appends;insertRow(0)puts it first.
allow-scripts only, so it cannot reach this page.const table = el('table', { id: 'sampleTable', border: '1' }, [
el('tr', {}, [el('td', {}, 'Row1 cell1'), el('td', {}, 'Row1 cell2')]),
el('tr', {}, [el('td', {}, 'Row2 cell1'), el('td', {}, 'Row2 cell2')]),
]);
document.body.appendChild(table);
function insert_Row(target, at = -1) {
const n = target.rows.length + 1;
const row = target.insertRow(at); // -1 appends
// A row with no cells renders as nothing at all, so always add cells.
row.insertCell().textContent = `Row${n} cell1`;
row.insertCell().textContent = `Row${n} cell2`;
return row;
}
// rows and cells are HTMLCollections, not arrays: no .map on them. Spread first.
const textOf = (t) => [...t.rows].map((r) => [...r.cells].map((c) => c.textContent).join(' | '));
console.log('before:', table.rows.length, 'rows');
insert_Row(table);
console.log('after :', table.rows.length, 'rows');
console.log(textOf(table));
insert_Row(table, 0);
console.log('and one at the top:');
console.log(textOf(table));
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 started with two rows', 2, 2);
test('there are four rows now', table.rows.length, 4);
test('the appended row is third from the start originally', textOf(table)[3], 'Row3 cell1 | Row3 cell2');
test('the row inserted at 0 comes first', textOf(table)[0], 'Row4 cell1 | Row4 cell2');
test('the original rows are still in order', textOf(table).slice(1, 3), ['Row1 cell1 | Row1 cell2', 'Row2 cell1 | Row2 cell2']);
test('every row has two cells', [...table.rows].every((r) => r.cells.length === 2), true);
test('rows is a collection, not an array', Array.isArray(table.rows), false);
test('so it has no map method', typeof table.rows.map, 'undefined');
test('a new row really is a TR', table.rows[0].tagName, 'TR');
test('and its cells are TDs', table.rows[0].cells[0].tagName, 'TD');
test('insertRow returns the row it made', insert_Row(table).tagName, 'TR');
test('so the count went up again', table.rows.length, 5);
test('deleteRow removes one', (() => { table.deleteRow(0); return table.rows.length; })(), 4);
test('and the right one went', textOf(table)[0], 'Row1 cell1 | Row1 cell2');
insertRow and insertCell are part of the table API and save you building tr and td elements by hand. Two details: a row inserted with no cells has no height, so it looks as though nothing happened — always insert cells straight after. And the index argument is a position, where -1 means append and 0 means put it first; the live demo has a button for each so the difference is obvious.
table.rows is a live collection in a real browser, meaning it updates as you change the table rather than being a snapshot. So caching rows.length before a loop that inserts rows gives you an infinite loop.
Answers JS DOM 5 in the original set.
6 Update one cell of a table
fairWrite a function taking a row number, a column number and a string, and put that string in that cell.
Stuck? Three hints, smallest first
- 1.
table.rows[r].cells[c]reaches the cell. Both are zero-based. - 2. An out-of-range index gives
undefined, and the next property read throws. - 3. Prefer
textContenttoinnerHTMLfor text you did not write yourself.
allow-scripts only, so it cannot reach this page.const table = el('table', { id: 'myTable', border: '1' }, [1, 2, 3].map((n) =>
el('tr', {}, [el('td', {}, `Row${n} cell1`), el('td', {}, `Row${n} cell2`)])
));
document.body.appendChild(table);
function changeContent(target, rowIndex, columnIndex, text) {
const row = target.rows[rowIndex];
// Guard both levels: an out-of-range index gives undefined, and reading
// .cells on undefined throws a TypeError.
if (!row) throw new RangeError(`no row ${rowIndex}; rows are 0 to ${target.rows.length - 1}`);
const cell = row.cells[columnIndex];
if (!cell) throw new RangeError(`no column ${columnIndex} in row ${rowIndex}`);
cell.textContent = text; // textContent, not innerHTML
return cell;
}
const textOf = (t) => [...t.rows].map((r) => [...r.cells].map((c) => c.textContent).join(' | '));
const failure = (r, c) => {
try { changeContent(table, r, c, 'x'); return 'no error'; } catch (e) { return e.constructor.name; }
};
console.log('before:', textOf(table));
changeContent(table, 1, 1, 'changed!');
console.log('after :', textOf(table));
console.log('bad row :', failure(9, 0));
console.log('bad column:', failure(0, 9));
// textContent treats markup as text. innerHTML would not, which is the whole
// reason to prefer it for anything a user supplied.
changeContent(table, 2, 0, '<b>not bold</b>');
console.log('markup put in with textContent stays text:', table.rows[2].cells[0].textContent);
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('the target cell changed', table.rows[1].cells[1].textContent, 'changed!');
test('its neighbour did not', table.rows[1].cells[0].textContent, 'Row2 cell1');
test('nor did the row above', textOf(table)[0], 'Row1 cell1 | Row1 cell2');
test('indexes are zero-based, so 0,0 is the first cell', changeContent(table, 0, 0, 'first').textContent, 'first');
test('there are still three rows', table.rows.length, 3);
test('and two columns', table.rows[0].cells.length, 2);
test('a row that does not exist is refused', failure(9, 0), 'RangeError');
test('a column that does not exist is refused', failure(0, 9), 'RangeError');
test('a negative index is refused too', failure(-1, 0), 'RangeError');
test('markup set via textContent stays text', table.rows[2].cells[0].textContent, '<b>not bold</b>');
test('so it created no element', table.rows[2].cells[0].children.length, 0);
test('the function returns the cell it changed', changeContent(table, 0, 1, 'z').tagName, 'TD');
table.rows[r].cells[c] is the path, and both indexes are zero-based. The guard matters more than it looks: rows[9] is undefined, so rows[9].cells throws TypeError: Cannot read properties of undefined — an error that names the symptom and not the cause. Checking each level and throwing a message that says what the valid range is turns a puzzle into a sentence. Try an out-of-range row in the live demo.
textContent rather than innerHTML is the security habit. The assertion shows '<b>not bold</b>' arriving as visible text with no element created. Had that been innerHTML, any text originating from a user could inject markup, and the same door lets in a <script> or an onerror handler. Use innerHTML only for markup you wrote yourself.
Answers JS DOM 6 in the original set.
7 Build a table from scratch
fairTake a number of rows and columns, and build the table, filling each cell with its own position, like Row-0 Column-0.
Stuck? Three hints, smallest first
- 1. Two nested loops: rows outside, columns inside.
- 2. Build the whole table before putting it in the page, so the browser lays out once instead of per cell.
- 3. A
DocumentFragmentis the standard way to stage nodes off-document.
allow-scripts only, so it cannot reach this page.function createTable(rows, columns) {
if (!Number.isInteger(rows) || !Number.isInteger(columns) || rows < 0 || columns < 0) {
throw new RangeError('rows and columns must be whole numbers, 0 or above');
}
// Built detached from the document, then attached once. A browser can lay out
// and paint after every insertion if you build in place; this way it does that
// work a single time.
const table = document.createElement('table');
table.setAttribute('border', '1');
for (let r = 0; r < rows; r++) {
const row = table.insertRow();
for (let c = 0; c < columns; c++) {
row.insertCell().textContent = `Row-${r} Column-${c}`;
}
}
return table;
}
const host = el('div', { id: 'host' });
document.body.appendChild(host);
host.appendChild(createTable(3, 4));
const grid = (t) => [...t.rows].map((r) => [...r.cells].map((c) => c.textContent));
const built = host.querySelector('table');
const refused = (r, c) => {
try { createTable(r, c); return 'no error'; } catch (e) { return e.constructor.name; }
};
console.log('3 x 4:');
for (const row of grid(built)) console.log(' ', row.join(' '));
console.log('cells:', built.rows.length * built.rows[0].cells.length);
console.log('0 x 0 gives an empty table:', createTable(0, 0).rows.length, 'rows');
console.log('a fractional count is refused:', refused(2.5, 2));
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('three rows were made', built.rows.length, 3);
test('each with four cells', [...built.rows].every((r) => r.cells.length === 4), true);
test('twelve cells in total', [...built.rows].reduce((n, r) => n + r.cells.length, 0), 12);
test('the first cell names its own position', grid(built)[0][0], 'Row-0 Column-0');
test('the last cell too', grid(built)[2][3], 'Row-2 Column-3');
test('every cell is unique', new Set(grid(built).flat()).size, 12);
test('every cell follows the pattern', grid(built).flat().every((t) => /^Row-\d+ Column-\d+$/.test(t)), true);
test('the table went into the page', built.parentNode === host, true);
test('zero rows gives an empty table', createTable(0, 0).rows.length, 0);
test('zero columns still gives rows', createTable(3, 0).rows.length, 3);
test('but with no cells', [...createTable(3, 0).rows].every((r) => r.cells.length === 0), true);
test('a single cell', grid(createTable(1, 1)), [['Row-0 Column-0']]);
test('a fractional count is refused', refused(2.5, 2), 'RangeError');
test('a negative count is refused', refused(-1, 2), 'RangeError');
Building the table detached and attaching it once is the habit worth forming. Insert into a live document inside a loop and the browser may recalculate layout on every pass; build it off-document and it does that work once. For a 3×4 table nobody could measure the difference, but the same loop over a few thousand rows is the difference between instant and visibly slow.
Note host.textContent = '' in the demo as the way to empty a container. It is shorter than looping over removeChild, and unlike innerHTML = '' it involves no markup parsing at all.
Answers JS DOM 7 in the original set.
8 Remove an item from a dropdown
fairA select lists Red, Green, White and Black. Remove the selected one when the button is pressed.
Stuck? Three hints, smallest first
- 1.
select.remove(index)orselect.options[i].remove()both work. - 2. After a removal every later index shifts down by one.
- 3. Removing while looping forwards skips items. Loop backwards, or filter first.
allow-scripts only, so it cannot reach this page.const makeSelect = () => el('select', { id: 'colorSelect' },
['Red', 'Green', 'White', 'Black'].map((c) => el('option', {}, c)));
let select = makeSelect();
document.body.appendChild(select);
// options is an HTMLOptionsCollection. It has length and indexing but no map,
// so it must be spread into a real array first.
const names = (s) => [...s.options].map((o) => o.textContent);
function removecolor(target) {
const i = target.selectedIndex;
if (i === -1) return null; // nothing selected, nothing to do
const removed = target.options[i].textContent;
target.options[i].remove();
return removed;
}
console.log('start :', names(select));
select.selectedIndex = 2; // White
console.log('removed :', removecolor(select));
console.log('remaining :', names(select));
// The classic mistake: removing while walking forwards.
const forwards = makeSelect();
for (let i = 0; i < forwards.options.length; i++) forwards.options[i].remove();
console.log('looped forwards, survivors:', names(forwards), '<- every other one');
// Backwards is safe, because removing index i does not move anything below it.
const backwards = makeSelect();
for (let i = backwards.options.length - 1; i >= 0; i--) backwards.options[i].remove();
console.log('looped backwards, survivors:', names(backwards));
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 started with four options', 4, 4);
test('the selected one was removed', names(select), ['Red', 'Green', 'Black']);
test('three are left', select.options.length, 3);
test('and the indexes closed up', names(select)[2], 'Black');
test('removing again takes another', (() => { select.selectedIndex = 0; removecolor(select); return names(select); })(), ['Green', 'Black']);
test('emptying it completely leaves none', (() => { while (select.options.length) select.options[0].remove(); return select.options.length; })(), 0);
test('and selectedIndex becomes -1', select.selectedIndex, -1);
test('so removing from an empty select is safe', removecolor(select), null);
test('looping FORWARDS leaves half behind', names(forwards), ['Green', 'Black']);
test('which is a bug, not a feature', forwards.options.length === 0, false);
test('looping BACKWARDS removes them all', names(backwards), []);
test('a static copy is the other safe way', (() => { const s = makeSelect(); for (const o of [...s.options]) if (o.textContent !== 'Red') o.remove(); return names(s); })(), ['Red']);
test('options is not an array', Array.isArray(makeSelect().options), false);
test('so .filter would throw on it', typeof makeSelect().options.filter, 'undefined');
The removal itself is one line. The exercise is really about the loop. Removing index 0 shifts everything down, so the next pass at index 1 is looking at what used to be index 2 — the assertions show four options becoming two rather than none. Loop backwards, because removing index i never moves anything below it. The live demo has a button for each so you can watch the wrong one skip.
The same trap applies to splice inside a forward for loop over an array, and to any live DOM collection like getElementsByTagName. Either iterate backwards or take a static copy first with [...collection].
Answers JS DOM 8 in the original set.
9 Count the items in a dropdown
easyCount the options in a select and display them. The original uses alert; return the text instead so it can be checked.
Stuck? Three hints, smallest first
- 1.
select.options.lengthis the count.select.lengthgives the same number. - 2.
optionsis a collection, not an array, so spread it before usingmap. - 3. An option’s
textand itsvalueare different things when avalueattribute is present.
allow-scripts only, so it cannot reach this page.const select = el('select', { id: 'mySelect' },
['Red', 'Green', 'Blue', 'White'].map((c) => el('option', {}, c)));
document.body.appendChild(select);
// An option whose visible text differs from its submitted value.
const withValues = el('select', {}, [
el('option', { value: 'LOS' }, 'Lagos'),
el('option', { value: 'ABV' }, 'Abuja'),
]);
document.body.appendChild(withValues);
function getOptions(target) {
const items = [...target.options].map((o) => o.textContent);
// The original calls alert(). Returning the message is the same information
// without freezing the page, and it can be asserted.
return { count: target.options.length, items, message: `${items.length} items: ${items.join(', ')}` };
}
const result = getOptions(select);
console.log(result.message);
console.log('options.length:', select.options.length, ' select.length:', select.length);
console.log('--- text is not always the value ---');
console.log('texts :', [...withValues.options].map((o) => o.textContent));
console.log('values:', [...withValues.options].map((o) => o.value));
console.log('an empty select counts 0:', el('select', {}).options.length);
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('four options are counted', result.count, 4);
test('select.length agrees', select.length, select.options.length);
test('the names are read in order', result.items, ['Red', 'Green', 'Blue', 'White']);
test('the message reads properly', result.message, '4 items: Red, Green, Blue, White');
test('an empty select counts zero', getOptions(el('select', {})).count, 0);
test('and its message says so', getOptions(el('select', {})).message, '0 items: ');
test('options is a collection, not an array', Array.isArray(select.options), false);
test('so it has no map method', typeof select.options.map, 'undefined');
test('but spreading gives a real array', Array.isArray([...select.options]), true);
test('which does have map', typeof [...select.options].map, 'function');
test('text and value differ when value is set', [...withValues.options].map((o) => o.value), ['LOS', 'ABV']);
test('while the text is what the reader sees', [...withValues.options].map((o) => o.textContent), ['Lagos', 'Abuja']);
test('adding an option changes the count', (() => { select.appendChild(el('option', {}, 'Black')); return select.options.length; })(), 5);
test('and the collection reflects it immediately', getOptions(select).items.at(-1), 'Black');
Adapted. The original pops the answer up in an alert. An alert cannot be asserted, it blocks the whole page, and it is genuinely unpleasant, so this returns the message and the demo writes it into the page. Same information, no modal.
select.options is an HTMLOptionsCollection, not an array — the assertion checks Array.isArray is false. It has length and indexing, so it looks like an array until you reach for map or filter and get a TypeError. [...collection] or Array.from(collection) fixes it. It is also live: the last two assertions add an option and see the count change with no re-query.
Keep option.text and option.value apart. The text is what the reader picks; the value is what gets submitted. They are the same string only while no value attribute is set.
Answers JS DOM 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.