Skip to content

The index table

The table state hook implements useTableState, including the query-string contract and the cell and filter vocabularies. Everything lives in packages/ui: the query-string codec in src/hooks/table_query.ts, the localStorage store in src/hooks/column_preferences.ts, the hook in src/hooks/use_table_state.ts, the components in src/components/table/, src/components/cells/ and src/components/filters/.

Server halves: the query pipeline (§8.3) and table columns (§7.5/§8.1).

The URL is the state

There is no table state anywhere except the address bar. useTableState parses page.url on every render and every mutation writes a new URL:

const table = useTableState(descriptor)

table.state // { search, filters, sort, page, perPage, cursor, columns, trashed }
table.setSort('publishedAt') // → /admin/posts?sort=publishedAt

That is what makes the back button work without any history bookkeeping of our own, and what makes a copied URL reproduce a colleague’s screen exactly. A shadow copy in React state would be a second source of truth and would drift the first time the user navigated history.

The one exception is searchDraft: a controlled input cannot wait 300 ms for a round trip before showing a keystroke. It is re-synced from the URL whenever no keystroke is queued, so history navigation still wins.

Every mutation is a partial reload

All setters funnel through one visit:

router.get(url, {}, { only: ['records'], preserveScroll: true, preserveState: true })

only: ['records'] is the whole point (§10.2): a sort click must not re-serialize the descriptor, which is the expensive half of the response. Search is debounced 300 ms; nothing else is, because nothing else fires per keystroke.

isLoading is driven by the visit’s own onFinish, so the loading affordance covers exactly the requests this hook issued and nothing else on the page.

Allowlists, mirrored

parseTableQuery drops unknown sort keys, unknown filter keys, unknown column keys and out-of-range perPage values — silently, exactly as the server does (§8.3). It is a deliberate duplicate of parseIndexQueryState (packages/core/src/table/pipeline.ts): if the two disagreed about what a URL means, the table would render controls the server ignores.

Serialization is the inverse and is ordered and default-eliding: page=1, perPage=<default> and trashed=default are omitted, and keys are emitted in a fixed order, so two equal states always produce byte-identical URLs. Inertia treats a differing URL as a navigation, so instability there would turn a no-op into a request.

Ranges are filters[k][from] / filters[k][to]; multi-values are filters[k][]=a&filters[k][]=b. Brackets stay literal — only keys and values are percent-encoded.

Sorting is tri-state, with a default underneath

aria-sort reports effectiveSort: the URL’s instructions, or the descriptor’s defaultSort when the URL pins none. The rows really are ordered by the default, so announcing “none” would be a lie.

The click cycle reads the URL’s sort instead: unsorted → asc → desc → unsorted. Cycling from the effective sort would make the first click on the default-sorted column a no-op. “Unsorted” therefore means “back to defaultSort” — the §10.2 contract has no way to express “explicitly unordered”.

Column visibility

Precedence is URL > localStorage > descriptor default. A shared link must show the sender’s columns, not the recipient’s saved preference.

The preference lives under adonia:<panel>:<resource>:columns (§8.1) as a JSON array of verbatim column keys, written whenever a column is toggled and replayed as ?columns=a,b,c. The server validates that list against its own allowlist and returns the columns in declaration order — ?columns= is a set, not a sequence.

The default visible set is every column whose props.defaultHidden !== true; the toggle menu lists every column whose props.toggleable !== false. descriptor.table.columns always carries the full column set, hidden ones included, so the menu can offer them.

Toggling is a real partial reload, not a CSS change: a hidden C.count column drops its sub-query server-side. The corollary is that rows only carry keys for currently visible columns, so every cell tolerates a missing value.

The store is an external store read through useSyncExternalStore, whose server snapshot is always “no preference”. SSR therefore renders the descriptor default deterministically and the client swaps in the stored preference after hydration.

Selection and action targets

selection holds page-local ids; selectAllMatching() escalates. That flips targets from { ids } to { query: state } (§10.3): a bulk action over 40 000 rows ships the query state and the server re-runs the same filtered pipeline, rather than the client posting 40 000 ids the server would have to authorize one by one. Running actions is Phase 3 (useAction); this slice owns the payload.

Any URL change clears the selection — the row set moved underneath it, and keeping ids across it would let a bulk action hit rows the user cannot see.

Cells and filters resolve through the registry

Columns resolve against the cell map, filters against the filter map. The v1 built-ins:

Map Types
cell text, badge, boolean, date, number, image, relation, count
filter select-filter, multi-select-filter, ternary-filter, date-range-filter, number-range-filter, relation-filter

An unregistered type renders <UnknownComponent> with a console warning and never throws (protocol v1 §8) — a C.custom column or a plugin whose client half is not installed must not take the index page down.

FilterProps is { node, value, setValue }: filters are fully controlled from the query state and own no state that could disagree with the address bar. The .trashed() ternary is wired to ?trashed= rather than filters[trashed], because it selects a soft-delete scope in the pipeline, not a filter predicate.

relation-filter uses the node’s options capability URL (protocol v1 §4) two ways: ?q= to search (same 300 ms debounce) and ?values= to hydrate the label of an already-selected id the current search page does not contain — without the second, reloading a filtered URL would show a blank select.

Accessibility

Real <table> semantics: scope="col" headers, a <caption> naming the resource, aria-sort on every sortable header, aria-expanded/aria-controls on the filter disclosure, and a <details>-based column menu so the disclosure is keyboard-operable with no focus-trap code. Pagination and sorting are <button>s, not links, because each one is a partial reload rather than a page visit.

tests/data_table.test.tsx, tests/cells.test.tsx and tests/filters.test.tsx each assert axe-clean output over the WCAG 2.0/2.1 A+AA rule sets.

Not yet covered

Cursor pagination (the cursor contract) needs descriptor.table.pagination on the wire before the table can switch to next/prev-only navigation and single-column sort; state.cursor is parsed and round-tripped today, but no UI drives it. Row and bulk actions are Phase 3 (useAction, §13.3).

Navigation

Type to search…

↑↓ navigate↵ selectEsc close