Lesson 12 / 25
Tables
Building two-axis position in markup, the row-header and column-header relationship, declaring and announcing sort state, and keeping the tab order under control in dense data.
Contents
The previous lesson made the results list able to declare its position. In the catalog, the same data is often more than a list: shelf code, title, year, and status split into columns, the columns can be sorted, and the row count runs into the hundreds. This structure is called a table, and in a table, position is two-axis — the user is in a row and a column at once.
A sighted reader joins the two axes with a glance: they look at a cell, see the column header going up and the row header going left. A user who cannot see the screen cannot make that join; which headers a cell is bound to has to be written in markup.
The Problem the Pattern Solves, and Where It Is Not Used
A table presents two-dimensional relational data: each row is an entity, each column is one of that entity’s attributes. Data belongs inside a table only if this relationship genuinely holds.
Using a table to build a layout fabricates this relationship: a row-column structure that does not exist is reported to a user who cannot see the screen, and the content turns into a meaningless grid. Layout is the job of the grid models in the Layout Systems and Responsive Design course; the table element is only for data tables.
The Native Element First, and the Header Relationship
The table’s native counterpart is complete: the table element, a caption, header cells, and data cells. A header cell states whether it is a column or a row header through its scope declaration. Once this declaration is written, the cell-to-header relationship is established on its own; no ARIA is needed.
The computation below builds this relationship on a model and counts what is lost when the row header is not marked.
// table.mjs — the header-cell relationship, sort state, and navigation in dense data // Table model: headers, rows, and each cell's value. const TABLE = { caption: "Northern Slope measurement records", columns: [ { key: "code", name: "Shelf code", type: "text", sortable: true }, { key: "title", name: "Title", type: "text", sortable: true, rowHeader: true }, { key: "year", name: "Year", type: "number", sortable: true }, { key: "status", name: "Status", type: "text", sortable: false }, ], rows: [ { code: "QB-141", title: "Astronomy Handbook", year: 1998, status: "on the shelf" }, { code: "QB-212", title: "Northern Sky Atlas", year: 2004, status: "on loan" }, { code: "QC-018", title: "Measurement Methods", year: 2011, status: "on the shelf" }, ], }; // 1. Header-cell relationship: which headers each data cell is bound to. // Rule: the column header comes from the table's top row, the row header comes // from that row's column marked "rowHeader". const rowHeaderColumn = TABLE.columns.find((c) => c.rowHeader); console.log("--- header-cell relationship ---"); console.log("cell bound headers"); let unmatched = 0; for (const row of TABLE.rows) { for (const col of TABLE.columns) { if (col.rowHeader) continue; const headers = [col.name, rowHeaderColumn ? row[rowHeaderColumn.key] : null].filter(Boolean); if (headers.length < 2) unmatched++; console.log(` ${String(row[col.key]).padEnd(24)} ${headers.join(" / ")}`); } } console.log(`cells matching fewer than two headers: ${unmatched}`); // 2. The same table built without a marked row header. const withoutRowHeader = { ...TABLE, columns: TABLE.columns.map((c) => ({ ...c, rowHeader: false })) }; const missingMatches = withoutRowHeader.rows.length * (withoutRowHeader.columns.length - 0); console.log(`\nwithout a marked row header: every cell matches only its column name`); console.log(` matches: 1 per cell (row identity is lost)`); console.log(` affected cells: ${missingMatches}`); // 3. Sort state: at most one column can be sorted. function sortState(columnKey, direction) { return TABLE.columns.map((c) => ({ name: c.name, sortable: c.sortable, state: !c.sortable ? "-" : c.key === columnKey ? direction : "none", })); } const state = sortState("year", "ascending"); console.log("\n--- sort state ---"); console.log("column sortable aria-sort"); for (const s of state) console.log(` ${s.name.padEnd(11)} ${(s.sortable ? "yes" : "no").padEnd(14)} ${s.state}`); const marked = state.filter((s) => s.state !== "none" && s.state !== "-").length; console.log(`marked columns: ${marked} (rule: at most 1)`); // 4. The announcement issued after sorting. function announce(columnName, direction, rowCount) { return `sorted by ${columnName}, ${direction}, ${rowCount} rows`; } console.log(`\nannouncement: "${announce("Year", "ascending", TABLE.rows.length)}"`); console.log(`announcement: "${announce("Title", "descending", TABLE.rows.length)}"`); // 5. The sort itself: the data type determines the comparison. const compare = (type) => type === "number" ? (a, b) => a - b : (a, b) => String(a).localeCompare(String(b), "en-US"); console.log("\n--- sort result (year, ascending) ---"); for (const s of [...TABLE.rows].sort((a, b) => compare("number")(a.year, b.year))) { console.log(` ${String(s.year).padEnd(6)} ${s.title}`); } console.log("--- sort result (title, ascending; locale-aware comparison) ---"); for (const s of [...TABLE.rows].sort((a, b) => compare("text")(a.title, b.title))) { console.log(` ${s.title}`); } // 6. Tab stop count in dense data: what happens if every cell is made focusable? const ROWS = 200, COLUMNS = 4; const allFocusable = ROWS * COLUMNS; const actionsOnly = ROWS * 1 + COLUMNS; // one action per row + column headers console.log("\n--- tab stop count (200 rows, 4 columns) ---"); console.log(` every cell focusable : ${allFocusable}`); console.log(` actions + headers only : ${actionsOnly}`); console.log(` ratio: ${(allFocusable / actionsOnly).toFixed(1)}x`); // 7. Column count consistency: checking for missing cells. const brokenRow = { code: "QC-100", title: "Incomplete Record" }; // year and status missing const checked = [...TABLE.rows, brokenRow]; console.log("\n--- column count consistency ---"); for (const s of checked) { const missing = TABLE.columns.filter((c) => s[c.key] === undefined).map((c) => c.name); console.log(` ${(s.code || "-").padEnd(8)} missing fields: ${missing.length ? missing.join(", ") : "none"}`); }
--- header-cell relationship --- cell bound headers QB-141 Shelf code / Astronomy Handbook 1998 Year / Astronomy Handbook on the shelf Status / Astronomy Handbook QB-212 Shelf code / Northern Sky Atlas 2004 Year / Northern Sky Atlas on loan Status / Northern Sky Atlas QC-018 Shelf code / Measurement Methods 2011 Year / Measurement Methods on the shelf Status / Measurement Methods cells matching fewer than two headers: 0 without a marked row header: every cell matches only its column name matches: 1 per cell (row identity is lost) affected cells: 12 --- sort state --- column sortable aria-sort Shelf code yes none Title yes none Year yes ascending Status no - marked columns: 1 (rule: at most 1) announcement: "sorted by Year, ascending, 3 rows" announcement: "sorted by Title, descending, 3 rows" --- sort result (year, ascending) --- 1998 Astronomy Handbook 2004 Northern Sky Atlas 2011 Measurement Methods --- sort result (title, ascending; locale-aware comparison) --- Astronomy Handbook Measurement Methods Northern Sky Atlas --- tab stop count (200 rows, 4 columns) --- every cell focusable : 800 actions + headers only : 204 ratio: 3.9x --- column count consistency --- QB-141 missing fields: none QB-212 missing fields: none QC-018 missing fields: none QC-100 missing fields: Year, Status
The Row Header Carries Identity
The first block of output shows that every data cell binds to two headers: the column name and the row’s identity. The value “1998” alone is a number; “Year / Astronomy Handbook” turns it into a fact.
The second block counts what happens when the row header is not marked: every one of the twelve cells matches only its column name, and row identity is lost. A user moving between cells hears “1998” without learning which record’s year it is. In every row, one cell — usually the one carrying the title or the identifier — is marked as the row header.
Sort State Is a Declaration
The third block audits sort state. The rule has two parts. First, every sortable column header must declare its sort state; a column that is not sortable carries no such declaration at all. Second, at most one column can be sorted at a time; two columns marked at once reports a contradictory state to the user.
The sort control itself is a button inside the header cell. The reason for using a button rather than making the whole header cell clickable was established in the Link and Button Distinction lesson: an action is a button, a button is reachable from the keyboard, and it is declared by its role.
What Gets Announced After a Sort
The fourth block produces the announcement string. Sorting does not change the page; it reorders the table’s content. That change is visible on screen the instant it happens, but if it is not announced, it is silent for a user who cannot see the screen.
The announcement carries three pieces of information: which column, which direction, how many rows. Row count matters because when sorting runs together with a filter, the row count can change too. The announcement is issued from a polite live region; sorting is the user’s own action and does not need to interrupt.
The fifth block shows the sort itself: the comparison depends on the data type. The number column sorts numerically; the text column sorts with a locale-aware comparison. String comparison that is not given a locale falls back to code-point order, which can break the reader’s expected letter order — this is the table-side counterpart of the rule established in the Internationalization API lesson.
Navigating Dense Data
The sixth block puts a number on the problem: in a two-hundred-row table, making every cell focusable adds eight hundred stops to the tab order. When only the actions and the column headers are focusable, that number drops to two hundred and four — roughly a quarter.
Arrow-key navigation between table cells is needed only when the table is built as a grid control: when the cells are editable, or selecting a cell is itself an action. In a read-only data table, cells are not made focusable; the user moves through the content in reading mode, and the tab order carries only the actions.
The last block audits column count consistency. A row that leaves a cell out shifts the header mapping, and values line up with the wrong header while reading. A cell can be deliberately left blank; a missing cell is a defect.
Keyboard Contract
| Key | Behavior |
|---|---|
Tab |
Moves to the next action: sort button, row action, pagination |
Enter / Space |
Runs the sort button under focus, reverses the direction |
| Arrow keys | Not a table shortcut in a read-only table; reading mode’s own navigation runs |
Measurable Constraints
- Every sortable header cell declares its sort state, and at most one is sorted at a time.
- Each row has one cell marked as the row header; column headers declare their scope (WCAG 1.3.1).
- The sort button’s target size cannot be smaller than 24×24 pixels (WCAG 2.5.8); this measure is already met once the whole header cell is made clickable.
- Sort direction cannot be declared by an arrow icon alone; the state declaration is a textual channel (WCAG 1.4.1).
- Wide tables that need horizontal scrolling must have a scrollable container reachable from the keyboard.
Common Mistake
The most common mistake is using a table as a layout tool. The second is writing header cells as a styled data cell: a bold, centered data cell looks like a header visually but is bound to no cell at all. The third is declaring the sort only with an icon and skipping the state declaration; in that case which column is sorted never reaches a keyboard user at all.
Summary
- Position in a table is two-axis; which row and column header a cell is bound to is written in markup, and the relationship a glance builds cannot be assumed.
- When a row header is not marked, every cell matches only its column name and row identity is lost; in the model, all twelve cells are affected.
- A sortable column declares its sort state, at most one column is marked at a time, and the post-sort announcement carries the column, the direction, and the row count.
- The comparison depends on the data type; text columns sort with a locale-aware comparison.
- In a read-only table, cells are not made focusable: in a two-hundred-row table, this decision brings the tab stop count from eight hundred down to two hundred and four.
Next Step
In every pattern so far, content stayed in place and the user reached it at their own pace. The featured-records strip on the catalog’s home page does the opposite: content changes on its own, and the user moves on to the next card before finishing reading. The next lesson takes on this pattern and puts a number on the cost of automatic transitions: that the motion has to be stoppable, the relationship between reading time and the transition interval, and why announcing content that changes on its own is usually the wrong choice.
To keep your progress and take notes, Log in
My notes
Log in to take notes.