Four hundred and twenty-nine lines, the largest component in the application, and the one users spend the most time in. It sorts, pages, switches between a transaction list and a month-by-item pivot, and does the accessibility work that separates a table from a grid of divs. Checkpoint: ui-09.

A toggle that changes the query

The Detail/Summary switch is the interesting part of this component, because it does not reshape data on the client:

export function setTableView(view: TableView): void {
    tableView.set(view);
}

tableView is one of the signals the query runner watches. Flipping it changes what the runner asks for — in Detail view the pivot slot resolves to null and pivotByMonth never runs; in Summary view it does.

So the most expensive query in the application only executes while something is displaying it. The component does not fetch, does not cache, and does not transform; it renders whichever result the store holds. The button changed a signal, and the query layer noticed.

This is worth contrasting with the shape most dashboards take, where a view toggle filters or reshapes data already in memory. That works until the dataset is larger than memory. Pushing the decision into the query means the summary view costs one PIVOT regardless of how many rows it summarises.

Semantics the layout cannot fake

A table built from divs is invisible to anyone navigating by table. This one uses the elements and the attributes that come with them:

html`
  <table>
    <caption class="visually-hidden">Filtered sales transactions</caption>
    <thead>
      <tr>
        <th scope="col">…</th>
      </tr>
    </thead>
    <tbody>…</tbody>
  </table>
`

<caption> names the table, so a screen reader announcing “table, Filtered sales transactions, 3 columns” tells the user what they landed in. visually-hidden keeps it out of the visual design without removing it from the accessibility tree — which is what display: none would do.

scope="col" on every header binds it to its column, so navigating cell by cell announces “Amount, 27” rather than “27”. With three columns you could argue the browser infers it; with a pivot’s seven columns and dynamic headers, inference is not something to rely on.

The pagination controls are a <nav aria-label="Pagination"> of real buttons, each with an aria-label because their visible content is a glyph. is not a word; “First page” is.

Announcing a change nobody sees

Paging updates the table body, and a screen reader user gets no notification — focus stays on the button they pressed, and the content that changed is elsewhere in the document.

html`<p class="page-info" aria-live="polite">
  Page ${page} of ${totalPages} · ${totalRows.toLocaleString()} rows
</p>`

aria-live="polite" makes that region announce itself when its text changes, after the user’s current utterance finishes. This is the smallest possible fix for a real problem, and the pattern generalises to any content that updates without moving focus.

polite rather than assertive because a page change is not an emergency — assertive interrupts, and a table that interrupts on every click is worse than one that says nothing.

Sorting is three lines and a state machine

The headers are buttons calling one action:

export function setSort(column: SortColumn): void {
    if (sortColumn.get() === column) {
        sortDirection.set(sortDirection.get() === 'asc' ? 'desc' : 'asc');
    } else {
        sortColumn.set(column);
        sortDirection.set('asc');
    }
    currentPage.set(1);
}

Click the active column and it flips direction; click a different one and it starts ascending. The page reset is there for the same reason it is in every other action — sorting from page 40 should not leave you on page 40 of a differently ordered result.

The component renders aria-sort on the active header and a / glyph, so the sort state is available both visually and to assistive technology. The sorting itself happens in the database, via the whitelist from engine lesson 6.

Skeletons that hold their shape

While the first query runs the table renders placeholder rows — the same count as the current page size, at the same height as real rows.

That last detail is the whole point. A skeleton that is shorter than its content causes the page to jump when data lands, which is exactly the experience the skeleton was added to prevent. Matching the row height means the layout is already correct and only the content changes.

Build it

File Action What goes in it
src/components/data-table.ts write Detail/Summary toggle, sortable headers with aria-sort, pagination nav with labelled buttons, aria-live page info, caption and scopes, pivot rendering, skeleton rows
src/components/page-size-select.ts write A labelled select calling setPageSize
index.html modify <data-table> after the charts, <page-size-select> in the filters row
src/index.ts modify Import both
src/style.css modify Add the table section to the panel rule

Done when: the table shows a caption, scope on every header, and an aria-live page indicator; changing page size to 100 renders 100 rows; and Summary swaps the headers to Item plus the six month columns.

Answer key: ui-08...ui-09.

Challenge: page to the end of a filter

Select two items, set the page size to 25, and page to the last page. Work out how many rows should be on it — total matching rows modulo 25 — before you look. Then confirm the page count matches ceil(total / 25). This is the pagination check the engine course could not perform, because it had no filter controls; you are now holding both halves.

Next lesson: the console returns as a component, and the app starts reporting on itself.