Context
0303 decision 7 gave every component one props vocabulary, and 0304, 0311 and 0312 extended it. Wave 5 adds the data primitives: Table, List with ListItem, PropertyList, Tabs with Accordion, CursorPagination with Breadcrumbs, and the three state pages EmptyState, ErrorState and PageSkeleton. Table, List and Tabs each take a list of items, can select some of them and have an empty and a loading state, and the runtimes disagree on every word: React Aria hands back aSet or the string "all" and names a loading table by an extra row,
HeroUI Native’s accordion takes value and selectionMode, the web accordion expandedKeys and
allowsMultipleExpanded, and neither knows Stackra’s cursor pages (nextCursor, hasMore). The sort of a table is
the same everywhere: Stackra’s pages are sorted by the server, so no table holds a sort of its own. An Accepted ADR’s
body changes only for typos and Enforcement, so the additions are recorded here.
Decision
-
The wave 5 vocabulary. Decision 7 of 0303 stands; wave 5 adds these rows:
-
A selection is a list of string ids. Every key is an item’s
id, reported in the items’ order, never aSetand never"all": select-all on a table reports every id.keysOfSelectionis the one function that turns a React Aria or HeroUI Native selection into that list, so the wrappers of Table, List, Tabs and Accordion share it. Tabs takes the selection props withoutselectionMode, because a tab list always holds exactly one tab, and reports a list of one id. -
Sorting is controlled and belongs to the server. A table takes
sortDescriptorand reportsonSortChange; it never reorders theitemsit was given.SortDirectionisascendingordescending, the valuesaria-sortand React Aria use, and the sort is a columnidplus a direction. The native adapted table has no header to press, so it asksnextSortDescriptorfor the sort a chosen column moves to. -
Two paging words, never one for both.
onNextandonPreviousare a button the user presses on CursorPagination, over a cursor page’shasMoreand ahasPreviousthe app keeps from its cursor history.onLoadMoreis the end of the list reached: the web table’sTable.LoadMorerow and the native list’s end-reached callback. Neither ever takes a page number: Stackra has no total. -
Empty and loading are two states.
emptyStateshows only whenisLoadingis false anditemsis empty. WhileisLoadingis true the collection is busy and names what loads byloadingLabel, spoken once and not repeated for every placeholder row.emptyStateis a node, so an app passes anEmptyState, and no wave 5 component imports another: the pieces meet in the app’s code and in the DataTable of wave 7 through these slots. -
The state pages are named by their reason, not by
variant. EmptyState takesreason:no-data,no-resultsorpermission-denied. ErrorState takeskind:unauthenticated(HTTP 401, which sends the user to sign in),forbidden,not-foundorunexpected. PageSkeleton takeslayout:page,table,sectionorform. Each is a frozen tuple in its component’s own contract.variantstays the emphasis word of 0303, and a status code is not a prop because the error’s own code is already in the details an app passes. - The adapted native forms of 0303 decision 3 keep the web words. The native Table is a list (one row per item, no columns, a sort chooser in its header), the native Breadcrumbs draw nothing because the navigation header’s back button is the same job, and the native CursorPagination is the same buttons. A prop with no meaning in an adapted form is accepted and unread, and the component’s page says so.
-
Amended 2026-10-24, while building (decisions 7 and 8).
- Native Breadcrumbs draw a text trail of pressable steps instead of nothing: the catalogue’s reasoning (the navigation
header’s back button) holds only inside a stack, and a console deep link has no such header, so each step is a
Link(or plain text for the current page) with a mirrored chevron between steps. - ListItem takes
endContent(a node at the end of the row, such as a count or a status) andhasChevron(the mirrored chevron of a row that leads somewhere); a row that takes a selection mode never callsonPress, so a row selects or acts, not both. - Table takes a required
label(it names the table),columnsof aTableColumn(id,label,isRowHeader,isSortable,align),renderCell,variant,isStickyHeaderandmaxHeight; the native form reads the last three as nothing. - CursorPagination takes
mode(pagerorload-more),count,isLoadingand its labels; load-more mode callsonNext. - ErrorState takes
retryas one object (labelandonPress) so a retry never lacks its label, and EmptyState and ErrorState taketitleas a required node, since the design system carries no copy. - PropertyList copies only when a row has a
copyLabel; native has no clipboard in the free dependencies, so the app writes it inonCopy, and a native row draws its copy button only when the list has anonCopyas well as the row acopyLabel. A copy is always announced:copiedLabeldefaults to an English word until i18n ships. - Amended 2026-10-24, after the wave 5 review: ErrorState takes
isMessageShown, which defaults to true outside production and false in production, because a thrown server or driver message is an internal; an app whose error text is written for the user sets it. CursorPagination shows the spinner on the button that was pressed, and the web load-more button stays mounted, disabled, after the last page so focus is not lost. The native Breadcrumbs mark the current step selected and speak it with the trail’s name. The icons these components draw (copy,check,chevron-down, and on nativearrow-upandarrow-down) are in theUI_ICONSregistry of each runtime.
- Native Breadcrumbs draw a text trail of pressable steps instead of nothing: the catalogue’s reasoning (the navigation
header’s back button) holds only inside a stack, and a console deep link has no such header, so each step is a
Consequences
- An app feeds Table, List, Tabs and Accordion the same way from one query result, selects and sorts the same way on web and native, and a selection or a sort held in state is the same shape on both.
- A table in the console and a list on the phone share
onSelectionChangeandonSortChangehandlers. - A reader never has to know that React Aria hands back a
Setor that HeroUI Native’s accordion calls its keysvalue. - Each component reads a prop it does not use (a Tabs has no
selectionMode) as absent from its type, so a misuse is a type error, not a silent no-op.
Enforcement
packages/design-system/ui/src/core/functions/__tests__proveskeysOfSelectionandnextSortDescriptor, andpackages/design-system/ui/src/contracts/types/__tests__/collection-contract-props.test.tsproves the shared types.- Each component’s type test asserts that the runtime props extend the contract and refuse the raw HeroUI and React Aria
names this vocabulary replaces (
selectedKey,allowsMultipleExpanded,onEndReached,varianton a state page). tools/scripts/spec-trace.mjs(run bypnpm validate) refuses a spec id that no test covers.- Until a lint rule exists, review (
architecture-guardian) checks this page.
Alternatives
- Keep React Aria’s
Selection, aSetor"all". Not chosen: native has no such type, and"all"makes every handler branch on a string before it can read a key. - A page number for the pager. Not chosen: the cursor pages of 0294 carry no total, so “page 3 of 9” cannot be told truthfully.
variantfor the empty, error and skeleton shapes. Not chosen: it already means emphasis in 0303, and a reader would expectvariant="secondary"to work.- A table that sorts its own rows. Not chosen: with cursor pages the rows on screen are a slice, so sorting them would show an order the server does not have.