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.