Context
0303 decision 7 gave every component one props vocabulary. Wave 7 adds the resource index: DataTable with BulkActionBar, FilterBar, EntityPicker, NumberField, and DateField with DatePicker. A resource index is the screen an admin lives on: a server-driven list with a search box, filters, a sort, row actions, a selection and bulk actions on it. Its pieces share more than any earlier wave’s did: the same search, filters and sort feed FilterBar and DataTable, the same action feeds a row menu and a bulk bar, and the number and date bounds of the fields recur in the filters. The date fields also need a value type that React Aria, a form library and the phone’s platform picker can all hold. An Accepted ADR’s body changes only for typos and Enforcement, so the additions are recorded here.Decision
-
The wave 7 vocabulary. Decision 7 of 0303 stands; wave 7 adds these rows:
-
The query is one controlled object.
ResourceQueryholds the search text, the applied filters (a plain object keyed by filterid; a filter with no key is not applied) and the sort. FilterBar editssearchandfilterValues, DataTable editssort, and each callsonQueryChangewith the whole next query. Neither keeps a copy. The page is not in the query: a cursor is the server’s, the app keeps the history of cursors, and any change to the query returns to the first page.@stackra/navigationmirrors the query to the URL in a later wave; until then an app keeps it in its own state, and@stackra/uistays router-free (0303 decision 1). Three pure functions in core read it:isFilterValueEmpty,isResourceQueryFiltered(search text or a set filter; a sort never counts) andclearResourceQueryFilters(keeps the sort). -
Six filter kinds, one editor each.
textholds a string,numberaNumberRangeValue(min,max, eithernull),selectone key,multi-selecta list of keys,date-rangeaDateRangeValue(start,end, eithernull) andbooleana flag.selectandmulti-selecttake the shortSelectItemlist of wave 3; a filter over thousands of records is a laterentitykind built on EntityPicker, not a long option list. A number filter reuses NumberField’s bounds, step and format by name, and a date-range filter reuses the date bounds. An emptied filter drops its chip; “no” on abooleanis a value, not an empty one. -
A wave 7 piece may build on an earlier wave and on a leaf group of its own wave once that group is merged, never
on a sibling’s unfinished file. This keeps 0317
decision 4 for the parts that meet only in the app:
- DataTable and BulkActionBar are one group. DataTable draws BulkActionBar itself from
bulkActionsandAllMatchingSelectionContractProps, so a table with selection has one bar. - DataTable does not import FilterBar. It takes a
filterBarnode slot, drawn above the rows, and uses the query only to choose the filtered-empty state over the empty one and to mark the sorted header. The app puts aFilterBarin the slot with the samequery. - FilterBar’s number and date-range editors are NumberField and DateField. NumberField and DateField with DatePicker are leaf groups: they import only earlier waves, so FilterBar is built after both are on the branch.
- EntityPicker is a leaf group on ComboBox and TagGroup (wave 3). No other piece imports it in this wave.
- DataTable and BulkActionBar are one group. DataTable draws BulkActionBar itself from
-
One action type for a row and a selection.
ResourceActionis aMenuLeafItem(key, text, icon, shortcut hint, tone, disabled and why, link) whoseonPressmay return a promise. The handler closes over the record or the selected keys, so the table and the bar never hand them back. A pending promise shows the action busy and refuses a second press. A rejection leaves the selection untouched and is reported by the app, as 0139 says an action reports. The bar never changes the selection itself: the app clears it after a success, and the bar’s clear button callsonClearSelection. A row menu hands the same actions to an ActionMenu unchanged. -
Select all N across pages is two props and a flag. The pages are cursor pages, so the total is the server’s own
count and often unknown: without
totalCountthe choice is never offered. WithisAllMatchingSelectedtrue the selected keys are only the rows on screen and the app acts on the query, not on the keys. -
A day is a
CalendarDate. The date fields holdCalendarDate | nullof@internationalized/date: a day with no time and no zone, which is what a due date or a birthday is, and the type React Aria takes unchanged. A value ofnullis an empty field, as for Select. The contracts import the type only, so the entry carries no runtime code, and@internationalized/datebecomes a required peer of@stackra/ui, pinned in thefrontendcatalog to the versionreact-aria-componentsand@heroui/reactalready resolve (3.12.4). Native converts to and from the platform’sDateat local noon, so a day never shifts with the time zone. A time or date-time field is a later wave and widens the type then. -
Native date entry uses the platform. Native
DatePickeris@expo/ui’s communityDateTimePicker(@expo/ui/community/datetime-picker, in themobilecatalog at 57.0.25, the Expo SDK 57 version), the iOS and Android picker. NativeDateFieldis the adapted form, a field that opens that picker. WebDateFieldandDatePickerare HeroUI’s.@expo/uiis an optional peer used by the native entry only. -
Numbers are read and written with
Intl.NumberFieldholdsnumber | nulland takesformatOptionsfor percent, currency and unit. It clamps a typed or stepped value to the bounds. Web is HeroUI’s number field. Native is the adapted form on the text input and two buttons, so a phone keyboard shows the number pad. A money field is a later wrapper on it, not a prop. -
Where a runtime has no counterpart the page says so (0317
decision 7). Native DataTable is the adapted form of Table: a list of rows with the
filterBarslot above it and a bottom bar for the selection. Native FilterBar opens its editors in a sheet, and every native sheet of this wave uses HeroUI Native’s inset style, as the owner decided on 2026-10-24 (0317 decision 8). Native BulkActionBar is a bottom bar whose overflow is an action sheet. Native EntityPicker is a sheet with a search field and a list.
Consequences
- An app declares its filters once and gets the add-filter menu and chips on the console and the sheet on the phone from the same list, and mirrors one object to the URL.
- An action is written once and serves a row menu, a bulk bar and an ActionMenu.
- The date fields pass values to a form library, to React Aria and to the date-range filter without conversion, at the
price of a
CalendarDatethat a plain JSON schema does not describe: a form that stores the value converts it, and a URL codec writes ISO text. @stackra/uiadds two peers:@internationalized/date(required) and@expo/ui(optional, native only).- The six wave 7 groups can be built mostly alone: only FilterBar waits, for two small leaf groups.
Enforcement
packages/design-system/ui/src/contracts/types/__tests__/resource-index-contract-props.test.tsproves the shared types, and the three files inpackages/design-system/ui/src/core/functions/__tests__/prove the query functions.pnpm-workspace.yaml(catalog modestrict) pins@internationalized/dateand@expo/uiexactly, andtools/scripts/validate-repository.mjsrefuses a literal version.- Each component’s type test asserts that the runtime props extend the contract and refuse the raw names this vocabulary
replaces (
commands,hideStepper,minimumDate,searchParams). 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, and that no wave 7 file imports a sibling group’s file that is not yet on the branch.
Alternatives
- A date as an ISO string. Not chosen: it serialises and validates in a plain schema, but React Aria and the native picker both need a date object, so every field would parse and format on each render, and a typed string has no invalid-day check of its own. The URL codec and a form schema convert at their edge instead.
- A JavaScript
Date. Not chosen: it carries a time and a zone, and a day typed in one zone shifts in another. - DataTable holding its own search, filter and sort state. Not chosen: a table with state of its own cannot be mirrored to the URL by a binding it does not know, and FilterBar would need a second channel to reach it.
- DataTable importing FilterBar. Not chosen: the two groups are built in parallel, and a slot keeps each buildable and testable alone; the app composes them in one line.
- Separate
BulkActionandRowActiontypes. Not chosen: they differ only in what the handler closes over, which the type need not know, and a second type would stop a row action from reaching the bar. - Operators on filters (
$gte,$lte) as Medusa has them. Not chosen: a range with two optional bounds says the same for numbers and days, and the server turns it into whichever operators it has.
Supersedes and sources
Supersedes: none. Amends 0303 decision 7. The catalogue rows, section 3 gaps and section 5 of the component catalogue (wave 7 of its build order), the HeroUI 3.2.6 and HeroUI Native 1.0.10 component sources,@expo/ui 57.0.25 and react-aria-components 1.22.1. The owner’s approval of
@internationalized/date and @expo/ui as catalog additions on 2026-10-24.