Table
“Recovery reference LR-2079-0112. Next of kin: not found.”
The class goes on a real <table>. It is <th> with scope that lets screen readers say “Offer, EB 1,800” when the user is in a cell. If you build the table from <div> elements, that connection does not exist, and the numbers have no meaning.
How to use it
Section titled “How to use it”| Item | Condition | Offer |
|---|---|---|
| Smartgun link, left eye | Removed intact | EB 1,800 |
| Subdermal plating, torso | Dented | EB 950 |
| Boosted reflexes | Still running | EB 4,400 |
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/css/table/table.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/dist/lotek.css --><table class="lt-table" data-variant="striped"><caption>Appraisal LR-2079-0112</caption><thead> <tr> <th scope="col">Item</th> <th scope="col">Condition</th> <th scope="col" data-align="end">Offer</th> </tr></thead><tbody> <tr> <th scope="row">Smartgun link, left eye</th> <td>Removed intact</td> <td data-align="end">EB 1,800</td> </tr> <tr> <th scope="row">Subdermal plating, torso</th> <td>Dented</td> <td data-align="end">EB 950</td> </tr> <tr> <th scope="row">Boosted reflexes</th> <td>Still running</td> <td data-align="end">EB 4,400</td> </tr></tbody></table>import "@lo-tek/interfaces/tokens.css"import "@lo-tek/interfaces/table.css"lt is used in two ways, and which one depends on whether you create the element or find it.
If you create the markup, you spread the attributes in. That is the route in React, Astro and anything else that renders:
import { lt } from "@lo-tek/interfaces/react"
<div className={lt.table.scroll} tabIndex={0} role="region" aria-label="Appraisal"> <table {...lt.table({ variant: "striped" })}> <caption>Appraisal LR-2079-0112</caption> <thead><tr><th scope="col">Item</th></tr></thead> <tbody><tr><th scope="row">Boosted reflexes</th></tr></tbody> </table></div>The import goes to /react here, since the block is JSX. From there the keys are called className and htmlFor. In Astro and anything else without React you import @lo-tek/interfaces and get class and for.
If the element is already there, you set them on it. That is the route when you enhance HTML a server or a template has sent:
import { lt } from "@lo-tek/interfaces"
const table = document.querySelector<HTMLTableElement>("#appraisal")
lt.setAttributes(table, lt.table({ variant: "striped", hoverable: true }))lt.table.scroll gives the class for the container around it. See Type-safe use.
// Once for the whole app, in the page template: every stylesheet, from the WebJar.head { link(rel = "stylesheet", href = LotekWebJar.CSS) }On a Kotlin or Java server, the same with kotlinx.html and lo-tek-kotlinx-html. The builder functions are in Lt, with an enum for each list of values:
import kotlinx.html.*import no.lotek.*import no.lotek.html.lt
data class Item(val name: String, val condition: String, val offer: String)
fun FlowContent.appraisal( items: List<Item> = listOf( Item("Smartgun link, left eye", "Removed intact", "EB 1,800"), Item("Subdermal plating, torso", "Dented", "EB 950"), Item("Boosted reflexes", "Still running", "EB 4,400"), ),) { table { lt(Lt.table(variant = TableVariant.STRIPED)) caption { +"Appraisal LR-2079-0112" } thead { tr { th(ThScope.col) { +"Item" } th(ThScope.col) { +"Condition" } th(ThScope.col) { attributes["data-align"] = "end"; +"Offer" } } } tbody { for (item in items) { tr { th(ThScope.row) { +item.name } td { +item.condition } td { attributes["data-align"] = "end"; +item.offer } } } } }}| Attribute | Effect |
|---|---|
data-variant="striped" | A muted surface on every other row |
data-hoverable | Highlights the row under the mouse. Use only when the row can be clicked. |
data-align="end" on a cell | Right-aligns, for amounts and quantities |
Numbers are set in tabular-nums, so the digits have equal width and the column can be read downwards.
Sortable columns
Section titled “Sortable columns”A column that can be sorted has a button in its header, and the direction it is sorted in is in aria-sort on the <th>. The arrow is drawn from the same attribute, so what the screen reader says and what the reader sees are the same. The button fills the cell, so the header has the same height and indent as the cells below. Without aria-sort the button shows both arrows, muted.
| Offer | ||
|---|---|---|
| LR-2079-0113 | 4 April | EB 2,750 |
| LR-2079-0112 | 4 March | EB 7,150 |
<table class="lt-table"><caption>Recovery orders<span class="lt-sr-only">, columns with a button can be sorted</span></caption><thead> <tr> <th scope="col"> <button type="button" class="lt-table__sort">Reference</button> </th> <th scope="col" aria-sort="descending"> <button type="button" class="lt-table__sort">Recovered</button> </th> <th scope="col" data-align="end">Offer</th> </tr></thead><tbody> <tr> <th scope="row">LR-2079-0113</th> <td>4 April</td> <td data-align="end">EB 2,750</td> </tr> <tr> <th scope="row">LR-2079-0112</th> <td>4 March</td> <td data-align="end">EB 7,150</td> </tr></tbody></table>lt.tableSort() gives one attribute set for the cell and one for the button:
import { lt } from "@lo-tek/interfaces/react"
const recovered = lt.tableSort({ direction: "descending" })
<th scope="col" {...recovered.header}> <button {...recovered.button} onClick={() => sortBy("recovered")}>Recovered</button></th>Without direction the cell gets no aria-sort. lt.tableSort.isDirection() checks a value from the URL or the form, and tableSortDirections from the main entry point holds the two valid values.
The sorting itself is up to you. In React the app keeps the column and the direction in state and redraws the rows. If the server sends HTML, in Datastar or a template, it sorts and writes aria-sort on the right header. Without JavaScript the button can be a link, <a class="lt-table__sort" href="?sort=recovered&direction=ascending">, and the server sorts on the next page load. The link gets the class alone, since lt.tableSort().button also gives type="button".
If you redraw the table after a click, put focus back on the button the user pressed. Otherwise it ends up on <body>. List with filters does this in its demo.
Wide tables
Section titled “Wide tables”A table wider than the screen must be scrollable sideways, and that area must be reachable with the keyboard:
<div class="lt-table-scroll" tabindex="0" role="region" aria-label="Appraisal"> <table class="lt-table">…</table></div>tabindex="0" is not optional. Without it, nobody navigating by keyboard reaches the content beyond the edge. role="region" with a text makes the screen reader say what the area contains when focus lands there.
Classes you can style
Section titled “Classes you can style”| Class | What it is |
|---|---|
.lt-table | The table itself. The variant comes from data-variant. |
.lt-table-scroll | The container that lets a wide table scroll |
.lt-table__sort | The button in a sortable column header. The arrow is ::after. |
| Variable | Default |
|---|---|
--lt-table-cell-padding | var(--lt-spacing-2) var(--lt-spacing-3) |
See Customising for how the variables and the layer work.
A denser table
Section titled “A denser table”If you need more rows on screen, it is the space in the cells that should go down. That is one variable, and there is no data-size for it:
| Item | Offer |
|---|---|
| Smartgun link, left eye | EB 1,800 |
| Subdermal plating, torso | EB 950 |
<table class="lt-table compact"><caption>Appraisal LR-2079-0112</caption><thead> <tr> <th scope="col">Item</th> <th scope="col" data-align="end">Offer</th> </tr></thead><tbody> <tr> <th scope="row">Smartgun link, left eye</th> <td data-align="end">EB 1,800</td> </tr> <tr> <th scope="row">Subdermal plating, torso</th> <td data-align="end">EB 950</td> </tr></tbody></table>.compact {--lt-table-cell-padding: var(--lt-spacing-1) var(--lt-spacing-2);}Leave the font size alone. --lt-font-size-m in the table is the same as the body text, and numbers become harder to read down a column when they shrink. If you still want smaller text, it is an ordinary rule in your own CSS, not a variable, since it is not meant to be changed.
Accessibility
Section titled “Accessibility”- Every column header should be
<th scope="col">, and the row’s own key<th scope="row">. Then the screen reader knows which column and row a cell belongs to. - Give the table a
<caption>. It says what the table contains, and is the first thing announced. - Never merge cells to make a layout look right. Merged cells make navigation unpredictable.
- If rows should be clickable, put the link in a cell. A whole row that is clickable cannot be reached by keyboard.
aria-sortgoes on the<th>, not on the button, and only on the column the table is sorted by. Columns that are not sorted do not have the attribute.- Say in the
<caption>that the columns can be sorted, for example with.lt-sr-only. The screen reader reads the caption when the user reaches the table, and otherwise the buttons in the header are the only hint. - Screen readers announce a change in
aria-sortdifferently, and some not at all. If the change should be spoken, write it in an element withrole="status".