Emmanuel Akinfulubi

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

hard

Build 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. 1. Numbers right-aligned, text left-aligned, and font-variant-numeric: tabular-nums so the digits share a width.
  2. 2. Wrap the table in a container with overflow-x: auto, and give the container tabindex="0" so a keyboard can scroll it.
  3. 3. A totals row belongs in <tfoot>, and the total should be the sum of the rows above it.

index.html

<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">&#8358;18,000</td>
      </tr>
      <tr>
        <th scope="row">Kano</th>
        <td class="num">30</td>
        <td class="num">&#8358;22,000</td>
      </tr>
      <tr>
        <th scope="row">Enugu</th>
        <td class="num">18</td>
        <td class="num">&#8358;12,500</td>
      </tr>
    </tbody>
    <tfoot>
      <tr>
        <th scope="row">Total</th>
        <td class="num">62</td>
        <td class="num">&#8358;52,500</td>
      </tr>
    </tfoot>
  </table>
</div>

style.css

.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;
}
Rendered result. Shown, not machine-checked: there is no browser in the build.
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.

Checks for exercise 33

// 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(/&#8358;([\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

hard

Build 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. 1. Every for must match an id, and every aria-describedby must point at an element that exists.
  2. 2. aria-invalid="true" exposes the error state; style from that attribute so the two cannot drift apart.
  3. 3. The error message needs to be in the field’s aria-describedby, or it is only visible to people who can see it.

index.html

<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>

style.css

.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; }
Rendered result. Shown, not machine-checked: there is no browser in the build.
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.

Checks for exercise 34

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.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.