HTML and CSS, 33–34
Structure and styling together: text, links, images, lists, tables and forms.
Exercises 33 to 34 of 34
33 A data table, put together
hardBuild a finished fares table: caption, column and row headers, striped body, right-aligned figures that line up, a totals row that is actually correct, and horizontal scrolling instead of a broken layout on a narrow screen.
Stuck? Three hints, smallest first
- 1. Numbers right-aligned, text left-aligned, and
font-variant-numeric: tabular-numsso the digits share a width. - 2. Wrap the table in a container with
overflow-x: auto, and give the containertabindex="0"so a keyboard can scroll it. - 3. A totals row belongs in
<tfoot>, and the total should be the sum of the rows above it.
<div class="tablewrap" tabindex="0" role="region" aria-labelledby="fares-cap">
<table class="fares">
<caption id="fares-cap">Fares and seats, week 12</caption>
<thead>
<tr>
<th scope="col">Route</th>
<th scope="col" class="num">Seats</th>
<th scope="col" class="num">Fare</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Abuja</th>
<td class="num">14</td>
<td class="num">₦18,000</td>
</tr>
<tr>
<th scope="row">Kano</th>
<td class="num">30</td>
<td class="num">₦22,000</td>
</tr>
<tr>
<th scope="row">Enugu</th>
<td class="num">18</td>
<td class="num">₦12,500</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
<td class="num">62</td>
<td class="num">₦52,500</td>
</tr>
</tfoot>
</table>
</div>
.tablewrap {
overflow-x: auto; /* scroll the table, do not break the page */
max-width: 320px;
border: 1px solid #c9cad1;
border-radius: 8px;
}
/* The wrapper is focusable so it can be scrolled by keyboard, so it needs a
focus ring of its own. */
.tablewrap:focus-visible {
outline: 2px solid #3055d8;
outline-offset: 2px;
}
.fares {
border-collapse: collapse;
width: 100%;
font-size: 0.88rem;
}
.fares caption {
padding: 8px 10px;
text-align: left;
color: #5c5c66;
font-weight: 600;
}
.fares th,
.fares td {
padding: 7px 10px;
border-bottom: 1px solid #e4e5e9;
text-align: left;
white-space: nowrap;
}
.fares thead th { background: #17171a; color: #ffffff; }
.fares tbody tr:nth-child(even) { background: #f2f3f5; }
/* Figures line up only if the digits are the same width. */
.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.fares tfoot th,
.fares tfoot td {
border-top: 2px solid #17171a;
border-bottom: 0;
font-weight: 700;
}
8 checks that prove this answer
Assertions, run in the browser. The markup and styles above are handed to them
as html and css, so press Run and each check prints
ok. The build fails if a single one fails.
// Add the body up and compare with what tfoot claims, rather than trusting it.
const body = html.slice(html.indexOf('<tbody>'), html.indexOf('</tbody>'));
const foot = html.slice(html.indexOf('<tfoot>'));
const naira = (s) => [...s.matchAll(/₦([\d,]+)/g)].map((m) => Number(m[1].replace(/,/g, '')));
const plain = (s) => [...s.matchAll(/<td class="num">(\d+)<\/td>/g)].map((m) => Number(m[1]));
const sum = (xs) => xs.reduce((a, b) => a + b, 0);
test('three data rows in the body', naira(body).length, 3);
test('the fares add up to 52,500', sum(naira(body)), 52500);
test('and the total row says exactly that', naira(foot), [52500]);
test('the seats add up to 62, which the foot also matches', [sum(plain(body)), plain(foot)[0]], [62, 62]);
test('every column and row header declares a scope', (html.match(/scope="(col|row)"/g) || []).length, 7);
test('figures are right aligned with tabular digits', /tabular-nums/.test(css) && /text-align:\s*right/.test(css), true);
test('the wrapper scrolls instead of breaking the layout', /overflow-x:\s*auto/.test(css), true);
test('and is focusable, so it has its own focus ring', /tabindex="0"/.test(html) && /\.tablewrap:focus-visible/.test(css), true);
Output
The interesting checks here are the arithmetic ones: a totals row is a claim, and a table whose footer disagrees with its body is worse than no footer. Two accessibility details go together at the end. A scroll container needs tabindex="0" or a keyboard user cannot reach the hidden columns at all — and once it is focusable it needs a visible focus ring, which is why both are asserted as one. role="region" with aria-labelledby pointing at the caption gives that scrollable area a name instead of announcing an anonymous group.
34 A booking form, properly wired
hardBuild the whole form: grouped fields, a real label on every control, hints and errors that are announced, native validation, and one field shown in its error state. No placeholder standing in for a label.
Stuck? Three hints, smallest first
- 1. Every
formust match anid, and everyaria-describedbymust point at an element that exists. - 2.
aria-invalid="true"exposes the error state; style from that attribute so the two cannot drift apart. - 3. The error message needs to be in the field’s
aria-describedby, or it is only visible to people who can see it.
<form class="booking" action="#" method="post" novalidate>
<fieldset>
<legend>Passenger</legend>
<p class="field">
<label for="bk-name">Full name</label>
<input id="bk-name" name="name" type="text" autocomplete="name" required>
</p>
<p class="field">
<label for="bk-phone">Phone</label>
<input id="bk-phone" name="phone" type="tel" autocomplete="tel"
inputmode="numeric" pattern="0[0-9]{10}" required
aria-describedby="bk-phone-hint">
<span class="hint" id="bk-phone-hint">Eleven digits, starting with 0.</span>
</p>
</fieldset>
<fieldset>
<legend>Journey</legend>
<p class="field">
<label for="bk-route">Route</label>
<select id="bk-route" name="route" required>
<option value="">Choose a route</option>
<option value="abuja">Lagos to Abuja</option>
<option value="kano">Lagos to Kano</option>
</select>
</p>
<p class="field">
<label for="bk-seats">Seats</label>
<input id="bk-seats" name="seats" type="number" min="1" max="6" value="0"
required aria-invalid="true" aria-describedby="bk-seats-error">
<span class="error" id="bk-seats-error">Choose between 1 and 6 seats.</span>
</p>
</fieldset>
<button type="submit">Reserve</button>
</form>
.booking {
max-width: 320px;
font-size: 0.9rem;
}
.booking fieldset {
margin: 0 0 14px;
padding: 10px 14px 4px;
border: 1px solid #c9cad1;
border-radius: 8px;
}
.booking legend { padding: 0 6px; font-weight: 700; }
.field {
display: grid;
gap: 4px;
margin: 0 0 12px;
}
.field label { font-weight: 600; }
.field input,
.field select {
padding: 8px 10px;
border: 1px solid #8e8e98;
border-radius: 6px;
background: #ffffff;
color: #17171a;
font: inherit;
}
.field input:focus-visible,
.field select:focus-visible,
button:focus-visible {
outline: 2px solid #3055d8;
outline-offset: 1px;
}
.hint { color: #5c5c66; font-size: 0.8rem; }
.error { color: #a3341f; font-size: 0.8rem; font-weight: 600; }
/* Styled from the ARIA attribute, so the red border and the announced state
come from one source and cannot disagree. */
.field input[aria-invalid='true'] {
border-color: #a3341f;
border-width: 2px;
}
button {
padding: 9px 18px;
border: 0;
border-radius: 6px;
background: #3055d8;
color: #ffffff;
font: inherit;
font-weight: 600;
cursor: pointer;
}
button:hover { background: #2543b4; }
8 checks that prove this answer
Assertions, run in the browser. The markup and styles above are handed to them
as html and css, so press Run and each check prints
ok. The build fails if a single one fails.
const labels = [...html.matchAll(/<label for="([^"]+)"/g)].map((m) => m[1]);
const fields = [...html.matchAll(/<(?:input|select|textarea)[^>]*\bid="([^"]+)"/g)].map((m) => m[1]);
const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map((m) => m[1]);
const described = [...html.matchAll(/aria-describedby="([^"]+)"/g)]
.flatMap((m) => m[1].split(/\s+/));
test('four controls, four labels', [fields.length, labels.length], [4, 4]);
test('every label points at a control that exists', labels.every((f) => fields.includes(f)), true);
test('and every control has a label pointing at it', fields.every((f) => labels.includes(f)), true);
test('every describedby target exists in the markup', described.every((d) => ids.includes(d)), true);
test('the fields are grouped under legends', countTag(html, 'fieldset') === 2 && countTag(html, 'legend') === 2, true);
test('no placeholder is doing a label\'s job', /placeholder=/.test(html), false);
test('the broken field is exposed as invalid and styled from that', /aria-invalid="true"/.test(html) && /\[aria-invalid='true'\]/.test(css), true);
test('a real submit button, and focus is never merely removed', /<button type="submit"/.test(html) && /outline:\s*none/.test(css) === false, true);
Output
The fourth check is the one worth stealing for your own projects: an aria-describedby pointing at an id that does not exist fails silently — nothing looks wrong, the hint is simply never announced. Comparing the two lists catches it. Styling the error from [aria-invalid='true'] rather than a separate class means the red border and the announced state are the same fact. Note novalidate on the form: it is here so the preview can show the error state without the browser intercepting first, and in a real form you would only use it when you are handling validation yourself.