A table of records to search, filter, sort, and page through.
import { RecordTable, shownColumns, tableColumns, tableLayout } from '@oztix/roadie-components/record-table'import { Records, downloadCsv, useRecords } from '@oztix/roadie-components/records'import { recordFields, recordsToCsv } from '@oztix/roadie-core/records'
Columns with a cell, getRowId, and the useRecords instance hold functions, so they can't pass from a Server Component. Build them and render the table in a client component, a file that starts with 'use client'.
Describe the records once as fields, pick the fields to show as columns, and pass both with the data. Search, sorting from the headers, and paging come built in.
Search matches every word, in any order, in the text fields and the option labels of fields marked searchable. Text fields are searchable by default; other types aren't.
In TypeScript, give the builders your row type so every key is checked: recordFields<Show>() and tableColumns<Show>(fields).
Pass loading while records load and error when the load fails. With no records yet, loading shows still placeholder rows; with records, it dims them and runs a bar under the header. An error replaces the rows, and onRetry adds a Retry button. With no records, the empty state says whether none exist yet or none match, and offers to clear the search and filters.
RecordTable is useRecords and the Records parts put together. Build it from the parts to put controls where the screen needs them. Records.Content shows the view's layout, here the table from tableLayout(columns).
Records.Header holds the standard controls: the search and filter field, Records.Select for Select mode on narrow rows, with viewActions the saved view's actions as Records.ViewActions, Records.Options, and with actions, the table actions as Records.Actions at the end. searchLabel names the search when its placeholder doesn't. Pass children to replace them, such as a Records.Search with your own placeholder or aria-label beside other controls. Keep a table's search with its table, in its header, never in Pane.Search.
In a header under 40rem, the search takes the whole row while it has focus. The other controls hide from sight and screen readers, and come back once focus leaves the search. Tab out of the search shows them first, so it lands on the next one.
Records.BulkActions can sit anywhere inside the Provider: the table's Records.Content shows it in its header row.
Most record tables live in a Pane. Wrap the pane in Records.Provider, which shares the records without adding an element, and spread the parts across it:
Records.Header in Pane.Body, above the table. It sticks under the pane's header while the body scrolls, so the search stays in reach, and paints the pane's margins beside it so rows never show there. Pane.Header keeps the Pane.Title.Records.Content in Pane.Body, below the header. Its column headers stick under the header. Placed straight in Pane.Body, a wide table runs to the pane's edges, with the pane's inset inside its first and last cells, so its first column lines up with the search and columns scrolled sideways pass under any pinned ones. Narrow rows, a fill or maxHeight box, and a pane with a measure keep the inset.Records.Status anywhere inside the Provider. It renders nothing visible.Records.Pagination in Pane.Footer, which stays pinned at the bottom. The table's sideways scrollbar sits just above the footer.Records.BulkActions anywhere inside the Provider. The table's Records.Content shows it in place of the column headers while records are selected, and floats it over narrow rows.On a phone the table turns into cards: the event, its venue, and its status, with the start, sold, and gross as details. The second frame shows it at 390px. See Narrow layouts.
A field describes one fact about a record, and its type decides how the value reads everywhere it shows:
| Field type | Reads as |
|---|---|
text | The text. A list joins with commas. |
option | Each value's label from options. With a status map, a small Badge in the key's intent. |
location | A place, such as a city: each value's label from options, as an option reads. |
number | Its format, number by default. Figures end-align. |
money | Its format, currency by default. currency or currencyKey names a currency that isn't dollars. |
date | House formats: event and access dates in the venue's zone from timeZoneKey, timestamps in the reader's zone, a range when the field has an end. |
boolean | Yes or No. |
A record that holds nothing for a field shows "Not available". Text a field can't read shows as given: "To be announced" in a date field, or "On sale soon" in a number or money field, which also takes a quieter colour.
A header sorts by its field alone: words A to Z first, figures and dates largest or latest first, and a second click flips it. A field with sortable: false has no sort button.
A column presents one field. tableColumns(fields).field(key, options) takes:
pin to hold the column at the start while the table scrolls sideways. The column with narrow: 'title', or else the first pinned text column, or else the first text column, is the row's title, in strong text. A custom cell there inherits it.width as { min, grow } in rem, when the content's own width doesn't suit. Without it, each column sizes from a sample of its values.cell to render the value yourself, such as an order number in font-mono.kind: 'image' to show the value, an image URL, as a 40px thumbnail, with alt to describe it from the row. Its header is read to screen readers but not shown, and it never sorts from there; give its field searchable: false and sortable: false. A record without an image shows a neutral tile with an image icon.priority to hide the column as the table narrows. See Column priority.narrow to place the column when the table turns into list rows or cards. See Narrow layouts.The view's layout.columns orders and hides columns: order lists keys to bring forward and hidden lists keys to leave out. Pinned columns stay first and shown whatever the view says.
The header's search is a query field named "Search and filter". It searches the records as you type and adds filters as chips. A record shows only when it matches every chip.
Each show holds its suburb, and a location's places nest, so the example's "Location is in Brisbane" chip shows the shows in Fortitude Valley and West End, and "Location is in Queensland" would too. Remove the chip, or change it to is not in, to see the rest. Places covers how they nest.
Typing searches the searchable fields, and suggests the filters the text reads:
matchPick a suggestion with the arrows and Enter, or a click, to add its chip. Enter with no suggestion picked searches the text. A filter takes only the words it reads, and the rest stay in the field for the next one. Typing "melb this weekend" offers "Location is in Melbourne" and "Start date is this weekend".
Focus the empty field to list the fields to filter by. Pick one, and the list shows its values under a toggle of operators. Press an operator, or press Tab and Shift+Tab, to change it.
| Field | Operators | Values |
|---|---|---|
| Option or status | is, is not, and has all of for a multiple field | Its options, or up to 1,000 values its records hold. Past that, or when a server filters the records, you type the value. |
| Location | is in and is not in, which take in every place inside | Its places, each with where it is, such as "Suburb in Brisbane". Without options, its records' values, as an option's. |
| Text | contains, doesn't contain, is, and is not | What you type |
| Number or money | =, ≠, <, >, and between | What you type |
| Boolean | is | Yes and No |
| Date | is relative, is, is before, is after, and is between | Under is relative, quick dates, upcoming for an event date and recent for a timestamp. Under the others, a calendar |
Every field you can filter by ends its toggle with is empty and is not empty. A field that holds a list of dates can't be filtered yet, so it isn't listed.
$ and thousands commas, such as $1,500.Another option value taken under is joins that field's is chip, which matches any one of its values. An is empty chip takes the place of the field's other chips, and any other chip on the field takes the place of its is empty chip, as together they match nothing. A date chip takes the place of the field's earlier date chip, as two dates rarely overlap.
Click a chip, or press Enter on it, to edit it in the list. It turns back into a dashed chip in its place, with its value in the field and selected, so typing replaces it. The toggle shows the chip's operator, and the list ticks what the chip holds.
Only an option chip changes as you press an operator. Anything else waits for Enter, so pressing Tab past is empty keeps the chip's values. Backspace in the empty field removes the chip. Escape, Enter on the chip's value unchanged or with no new value to take, or leaving the field ends the edit and keeps the chip. While you edit, Left and Right move only within the text.
A date field starts on is relative, with its quick dates, such as Today, This week, and Next 7 days. Typing narrows them, and phrases like "rest of the month" read too.
Under is, is before, or is after, the dropdown shows a calendar under the values. Click or tap a day to add the chip, such as "Start date is on Thu 22 Oct". Typing suggests days above the calendar, such as Fri for "fri", and Enter takes the first. The calendar moves to the month of the first day, so "26 feb" shows February, and tints the day or range Enter would take. Under is between, click two days for a range, or type one, such as "22 oct to 30 oct".
The calendar stays in the dropdown at every screen size, as wide as the field. It shows two months side by side where they fit, and one on a phone, with a month and year select to jump further. Until you type, a hint says you can type a date instead. Dates are whole days, on a timestamp field too. Editing a date chip opens it on its operator, with the calendar on its month and its day or range marked. Picking another day changes the chip.
| Key | What it does |
|---|---|
/ | Focuses the search from anywhere on the page, except while you type in another field. |
| Enter | Takes the suggestion you moved to. With none, it takes an identifier the text matches, or searches the text. While you pick a value, it takes the first value once you type, or the one row under is empty or is not empty. While you edit a chip, with its value unchanged or no new value to take, it ends the edit. |
| Tab, Shift+Tab | While a field's values show, moves to the next or previous operator. From the last operator, or the first, it leaves the input and ends picking a value or editing a chip. |
| Backspace | In the empty field, selects the last chip, and a second removes it. While you pick a value, it goes back to the fields, and while you edit a chip, it removes that chip. |
| Escape | Ends picking a value or editing a chip, and keeps the chips, as leaving the field does. Otherwise it closes the suggestions, then clears the text. |
With several searches on a page, the first one takes /. Set searchFocusShortcut on RecordTable or Records.Header, or focusShortcut on Records.Search, to another key, a KeyboardEvent.key value such as s, or false.
A page that shows part of a list, such as one venue's shows or one event's tickets, passes scope: filters the page sets, not the person. They show first as locked chips with a "Set by this page" tooltip, and filter the records along with the view.
The scope is never part of the view. Saved views and the URL leave it out. Clear, and the empty state's Clear search and filters, keep it. A list filtered only by its scope counts as unfiltered.
In server mode, fetch records.request. Its query is the applied query with the scope's filters first, as is records.scopedQuery, and toMeilisearch reads it as it is. Table and bulk actions get the same query and request, so an action on every match stays within the page.
A scope holds any filter a field can apply. An app turns a rule such as "products on this event, its days, or its collection" into filters on its own fields.
A view holds the search, filters, sort, and layout; a position holds the page and page size. Both work uncontrolled, with defaultView and defaultPosition, or controlled, with view and onViewChange, and position and onPositionChange. A change to the search, filters, or sort returns to the first page: when the page changes, onPositionChange hears that first, then onViewChange gets the view and the new position, so a parent writing both to one URL ends with the two agreeing.
Keep the view in the URL with recordViewToSearchParams and recordViewFromSearchParams, as Records model shows. A view can name a filter on a field these records don't have, or one its field can't read, such as a date range that ends before it starts. The records skip it, show the rest, list it in skippedFilters, and warn in development. A sort on a field that doesn't sort is skipped too, with the same warning, and no header shows it.
Records.Options is the Configure button in the header, after the search and before any table actions, so their More actions menu stays last. It opens a popover below the button, lined up with its end, or a bottom drawer on a phone, and every change applies at once.
The options write the view, like the headers do: the sort to query.sort, and the columns to layout.columns, with the whole order once a column moves and the hidden keys. Put back the way the columns are defined, the layout leaves both out, so equalRecordViews reads it as unchanged. A view's keys for columns this table doesn't have, such as one only some people see, are kept. The button shows only when a field sorts or a column can move or hide: two columns that aren't pinned, or one beside a pinned column.
label renames the button, its tooltip, and the panel, which are "Configure table" for the table. In your own header, put Records.Options after the search and any Records.ViewActions, and before Records.Actions. A layout adds its own settings under the sort with a Settings component in its definition, as tableLayout does for the columns. A lazy one can carry a preload function, which Records.Options calls once the page is idle or its button is reached.
Give Records more than one layout, such as the table and a grid of cards, and Configure shows a Layout switcher above the sort that writes the view's layout. The button and panel follow the layout: "Configure grid" lists the card's fields. See Layouts on RecordGrid for an example.
A saved view is a view with a name that people come back to. Apps ship preset views for a screen, such as On sale and Sold out on an events screen, and people save their own beside them. Roadie applies views and tells when one has changed. Your app stores the views and lists them.
Pass the saved view the screen opened as baseline. modified says whether the view now shows something else, by equalRecordViews, which ignores the id, the name, spacing in the search, and the order of chips. resetView goes back to the baseline from the first page. The page's scope is never part of a view, so it never makes one read as modified.
viewActions on RecordTable or Records.Header takes the handlers below as one object, the props of Records.ViewActions, unlike the arrays actions and bulkActions take. It shows the baseline's name after the search and Select, before Configure, with a dot once the view is modified and a tooltip with the whole name. In your own header, put Records.ViewActions in the same place. Its menu offers what your handlers allow:
onSave with the view under the baseline's id and name. It shows while the view is modified.onSaveAs with the view, that name, and no id, for your app to assign one.onRename with the baseline under it. Unsaved changes stay unsaved.onDelete with the baseline. Open another view once it resolves.Leave out the handlers a view doesn't allow. A preset takes onSaveAs alone, so people can start their own view from it but can't change it for everyone. With no baseline, the button reads Unsaved view and offers Save as new view, and a baseline with no name reads Untitled view. A baseline with nothing to offer still shows its name, in a button that opens nothing, so focus stays put after a reset. With neither a baseline nor onSaveAs, it renders nothing.
Names are asked for in a dialog, or a bottom drawer on a phone, and Delete view asks in an alert dialog on every screen. A handler can return a promise, and the button or the dialog's action stays busy until it settles. Reject to say it failed. The dialog stays open and shows the error's message, so reject with one people can read, such as "You already have a view called Perth". Without a message it says plainly that the view wasn't saved, renamed, or deleted. While a handler runs, the dialog stays open. A failed Save view goes to reportError, so show your own toast too.
Keep the open view in the URL. recordViewToSearchParams writes its id as view, so a link opens the same view with the same changes. A link to a saved view as it is can carry its id alone, ?view=on-sale, which recordViewFromSearchParams reads as no view, so pass that saved view as the fallback. Then find the baseline by the view's id:
const saved = [...presets, ...myViews]const opened = saved.find((held) => held.id === searchParams.get('view')) ?? presets[0]const { view } = recordViewFromSearchParams(searchParams, fields, { fallback: opened })const baseline = saved.find((held) => held.id === view.id)
List the views where people move between pages: a Navigator.Secondary beside the records, or a List in a Pane of its own. Put the presets first and people's own views in a group after them, and title the records' pane with the view's name. Here, Save as new view adds a view to Your views and opens it, and Delete view goes back to All events.
Keep the views your app stores as RecordView JSON, the same shape as the URL reads, so a view saved on one screen opens from a link, a preset, or a search hand-off alike.
bulkActions turns on a checkbox for each record and shows the bulk actions once something is selected. An action gets the selection and the query and request it was taken against: the picked ids, or every matching record but some. It never gets a record the search and filters hide. Selection is session state, like the page, and works controlled with selection and onSelectionChange or uncontrolled with defaultSelection. A search or filter change drops picked records it hides, and drops a "select all".
rowActions returns menu items for a record, shown in a menu on its row named "More actions for" the record. getRowHref links each record to its page through the title column. When records can be selected, clicking anywhere else on a row selects it, and Shift extends the selection from the last one picked; otherwise the click follows the link. Cmd or Ctrl opens the record in a new tab. Return undefined for a record with no page.
While records are selected, the bulk actions take the column headers' place in the same sticky row, so the rows never move and the bar stays in view as the table scrolls down or sideways. The page checkbox stays at the start. The count, such as "3 selected", opens a menu with Clear selection, and with "Select all" once the whole page is checked and more records match. Actions follow as buttons, and those that don't fit move into a More actions menu at the end. The headers come back once the selection clears. Danger actions ask first unless confirm is false, and any other action asks when given confirm.
actions act on everything the search and filters match, with nothing selected, such as exporting an attendee list. Each gets the applied query, the request for every match, and the records. The first shows as a button at the end of the header and the rest sit in a "More actions" menu after it. In a header under 40rem they all sit in the menu. Danger actions confirm first, as bulk actions do. They stay in the header while records are selected, apart from the bulk actions in the header row, so it stays clear which actions use the selection.
recordsToCsv(rows, fields, { timeZone }) from @oztix/roadie-core/records writes rows as CSV, one column per field, each value as the table shows it, and downloadCsv saves it. In the browser, records.matchingRows holds every matching record in the current sort, across pages, and shownColumns(columns, records.view.layout) gives the columns the table shows, in its order. Pass { values: 'raw' } to write plain numbers a spreadsheet can sum. Text that starts like a formula, such as = or @, is written with a leading apostrophe so a spreadsheet shows it rather than runs it. A column with its own cell exports its field's value, not what your cell draws.
An action gets one object: query, the applied query with the scope first; request, that query resolved for a server, with relative dates fixed and no paging, as it means every record that matches; timeZone and now, the zone and moment request resolved in; and records. A bulk action gets selection in place of records.
When the browser doesn't hold every record, POST request and export on the server with the same fields. Records model has the request's shape. Have the server send the CSV without a byte order mark, since downloadCsv adds one.
totals adds a row of totals under the headings, over every record the search and filters match, not just the page. It reads "Totals for 1,234 shows" from recordName and the count, in the title column that carries the row link, and changes as people search and filter. With no title column, the label takes the first column without a sum that isn't an image, as on a DataTable. Use it when the totals belong to columns people read down. For two or three headline numbers, show StatTiles above the table instead.
totals (or 'sum') adds up the number and money fields in the browser. Shares, indexes, and points don't add up, so they stay blank, and so does money whose rows name their own currency through currencyKey. Give a column total: false to leave it blank too, as for an id held as a number. On a RecordTable column it only hides the total, which records.totals still holds; on a DataTable column it leaves the column out of the sum. Each total reads in its field's format.
The totals row sticks with the headings and stays in view when records are selected, since the bulk actions cover only the headings. Under 40rem, the totals read as one line above the list rows or cards.
With server data, the server sums every match, as with an Elasticsearch sum aggregation beside the search, and you pass its figures as values, keyed by field. A ratio such as % sold doesn't add up, so work it out from the sums: total sold over total capacity. Keep passing the last totals while the next page loads, as you keep its rows. A label replaces the default.
With loadRange, a range can resolve with totals beside rowCount, and the list keeps the latest for the search. From useRecords, records.totals holds the label and values, for a layout of your own.
Pass rowCount and the records stop searching, filtering, sorting, and paging in the browser. data is one page, in the order the server returned it, and rowCount counts every record that matches. onRequestChange hands you the request to fetch once the page hydrates, then each time the view, position, zone, or now changes it. It carries the page, relative dates such as "today" fixed in the viewer's zone, the page's scope first, and only the filters and sorts the fields can apply, as the table shows them. From useRecords, records.request holds the same. The search waits for a 250ms pause in typing before the request or onViewChange hears it, so a request doesn't leave on every keystroke. Clearing it is immediate.
Set loading until the rows for the latest request arrive. To have it true in the very render where the view or position changes, hold both yourself, usually in the URL, and key loading on them. While loading or error is set, the records keep a page past the old count, so a linked page waits for the new count before it moves, and Records.Status waits for the new count before it announces it. Once given, rowCount keeps server mode on: left undefined while a page loads, it keeps the last count. Pass 0 until the first count arrives. Take each change onViewChange reports: a parent that ignores a search leaves the field showing it. Column widths come from the first page a search or filter brings, and hold as people page, so the columns don't shift.
Some backends stop counting, such as a search index that counts to 10,000. Pass rowCountExact={false} with the count it stopped at. Counts then read "10,000+": the pagination, Select all, the selection, the status, and the confirm dialog. Paging stops at the last whole page within the count, and a range list's last range ends at the count, so every request is one the server can return.
Selection works against the total. Records ticked on different pages stay selected, and Select all picks { allMatching: true, except }, counted from rowCount. A bulk action gets that selection and the query and request it was taken against, so the server acts on the same records. A new search or filter clears the selection, since only the server knows what still matches; a new sort or page keeps it. Selecting needs getRowId, as index ids repeat on every page. matchingRows holds only the page, so export on the server, as Table actions shows.
This example's server is pretend: it reads the request onRequestChange hands over, filters and sorts with core's compileRecordQuery and sortRecords, as an index would, waits half a second, and shows the request it was sent.
With Meilisearch, toMeilisearch(request, fields) from @oztix/roadie-core/records/meilisearch turns the request into a search. Its page and pageSize become page and hitsPerPage, so the response's totalHits counts the matches for rowCount. Meilisearch stops counting at the index's pagination.maxTotalHits, 1,000 by default, so raise it to the most records a screen can match, or pass rowCountExact={false} when totalHits reaches it. The request's relative dates are already fixed in the viewer's zone, so the server reads no clock. Records model lists the index shape it needs.
const { q, ...params } = toMeilisearch(request, fields)const response = await client.index('events').search(q, params)const page = { rows: response.hits, rowCount: response.totalHits }
To build a request away from the table, as for a link or a scheduled export, call toRecordsRequest(view.query, fields, { now, timeZone, scope }, position) from @oztix/roadie-core/records. It throws for a filter on a field the list doesn't have, where the table skips it, so check a stored view with validateRecordView first.
Pass loadRange for one long list with no pages that loads the records on screen as people scroll. The list asks for the rows on screen plus a screen either side, a page per request, each a request with start and end, end exclusive, to POST as it is. Each range is one whole page, page start / pageSize, so fetch pageSize records from start; the last one can run past the end of the list, unless the count is one the server stopped at, where it ends at the count. Resolve with { rows, rowCount }: the range's records, in order, and the total that matches, with rowCountExact: false when it is a floor. Leave out data; the list holds what it loads. Rows that haven't arrived show as grey placeholders.
The server searches, filters, and sorts, as in server mode, and the search waits for a pause in typing. The list keeps each range's records with the request they answer, drops a response for an older one, and starts over when the search, filters, sort, or zone change, or when a new now moves a relative date. It keeps the last total until the new query's first response, so the scrollbar holds its length. The first range brings the count, so a list that opens at a saved row asks for the first page, then the row's. A count that grows, as when a record is added at the end, loads the rows it adds. Every request in this example waits 400ms.
If loadRange rejects, the range shows an error row in place of its records, with Retry, and the records already loaded stay. When nothing has loaded yet, the table shows its error state instead. Range mode stops at 300,000 rows. With range loading, Records.Pagination shows the count instead of pages, such as "1,284 shows", or "50+ shows" while the total is unknown, with no page buttons and no rows per page: the list scrolls. Paging stays for paged tables.
Use range loading for long lists people scroll, open a record and come back to. Use pages when people jump to a known page, or the list is short.
With Meilisearch, toMeilisearch(request, fields) fetches each range as a page, so return its hits as rows and totalHits as rowCount. Raise the index's pagination.maxTotalHits to the longest list a screen can show.
Some APIs page with a cursor and never say how many records match. Resolve with rows alone and the list asks for one range at a time, from the end of what it holds, and stops once a range comes back short or empty. The scrollbar grows as records arrive. Keep each range's cursor by where it starts, and set the page size to the API's.
The list reports the first row on screen as position.row, through onPositionChange, a few times a second while people scroll. Keep it with the view, such as in the URL, and coming back to the list lands on the same row once the first range brings the count, loading the row's range before any between. Scrolling changes row often, so write it to the URL without adding history.
By default the page, or the pane, scrolls the rows down, and the table scrolls sideways on its own with a Roadie scrollbar that sticks to the bottom of whatever scrolls the page. Past 100 rows, only the rows near the screen render, so a long page scrolls smoothly. Find in page and printing then see only those rows, so keep pages to 100 where people search the page or print it. If the focused row scrolls away, focus moves to the table, keeping the reader's place. Give Records.Content (or RecordTable) a maxHeight to scroll the rows both ways in a box of their own, with the header and the pinned column held in place. fill does the same at its parent's height, such as a Pane.Body.
Give columns people can do without a priority, so they hide as a wide table narrows, before it turns into list rows. Priority 3 hides below 64rem of the table's own width, 2 below 56rem, and 1 below 48rem, and the header and rows hide together. Pinned columns and the title never hide, so pin and narrow: 'title' win over priority. A column hidden by priority still shows in Configure, and search and exports still include it.
Under 40rem of its own width, 640px at the default text size, the table drops its columns and lists its records. It measures the table, not the screen, so a table in a side panel turns into a list on a wide monitor too. Each column picks its place with narrow:
narrow | Where the column goes |
|---|---|
'title' | The record's title, in strong text. It carries the record's link. Without one, the pinned text column, then the first text column, is the title. |
'description' | One line under the title, in subtle text. |
'leading' | Before the title. An image column goes here unless you say otherwise. |
'trailing' | At the end of the row, such as a status badge. |
'detail' | A label and value on a card. Any detail column turns the rows into cards. |
'hidden' | Left out of the narrow layout. The default for every column but images. |
The server can't measure the table, so it renders wide, and a phone switches to the list once the page hydrates. List rows reach 12px into the space beside the table, as a List does, so their text lines up with the header: give the table at least that much inline padding, as a page or Pane does. In a box of its own, with maxHeight or fill, rows stay inside the box. The examples below sit in a 360px box.
Narrow rows load by range and render only near the screen past 100 records, as wide rows do. The bulk actions float at the foot of the screen, keeping the first action and putting the rest in a More actions menu when they don't fit.
With no detail columns, each record is a list row of a fixed height: leading, title, description, and trailing. An image column becomes the leading thumbnail. Switch to Wide to see the same table turn back into columns.
Give columns narrow: 'detail' and each record becomes a card, with the details listed under its title as labels and values. An image column becomes the card's 16:9 banner, with the trailing value on its top end. Cards grow to fit their content, and past 100 records the list measures each as it renders.
To choose for yourself, pass narrow: 'list' or narrow: 'cards' to tableLayout, or narrow to RecordTable. List rows leave out detail columns, and cards without details show just the title, description, and trailing.
A phone has no hover and no room for a checkbox column, so narrow rows that can be selected show a Select button in the header instead. Tap it to enter Select mode: each record shows a checkbox, a tap selects the record rather than opening it, and the header offers Select all, or Deselect all once the page is selected, and Done. Done, or Escape, leaves the mode and clears the selection. Going wide leaves the mode too but keeps the selection, and selecting from the wide table and then narrowing it enters Select mode on its own. Once records are selected, the floating bar shows the count, the offer to select every matching record and the bulk actions, with labels collapsed to icons.
RecordTable places the Select button for you. When you compose the parts, put Records.Select beside the search; it shows only while the layout selects through Select mode.
<Pane.Body><Records.Header searchPlaceholder='Search events' /><Records.Content /></Pane.Body>
Do
Put the search in the header above the rows, where it sticks while the body scrolls and clearly searches this table.
<Pane.Header><Pane.Title>Events</Pane.Title><Pane.Search value={search} onValueChange={setSearch} /></Pane.Header>
Don’t
Don't move a table's search into Pane.Search. It reads as a search of
the whole pane.
const field = recordFields<Event>()export const eventFields = [field.text('name', { label: 'Event' })]const column = tableColumns<Event>(eventFields)const columns = [column.field('name', { pin: true })]
Do
Build fields and columns once, outside the component, and share the fields with the server, filters, and URLs. Stable arrays keep rows from rendering again.
function Events() {const fields = [{ key: 'name', label: 'Event', type: 'text' }]return <RecordTable fields={fields} columns={...} data={events} />}
Don’t
Don't rebuild fields or columns on every render. The table resizes its columns and renders every row again each time.
column.field('name', { pin: true })
Do
Pin the column that names the record. It stays in view as the table scrolls sideways and reads in strong text.
column.field('name', { pin: true }),column.field('venue', { pin: true }),column.field('location', { pin: true })
Don’t
Don't pin several columns. Each one takes width from the columns that scroll.
<RecordTable scope={[{ field: 'venue', operator: 'is', values: [venueId] }]} {...props} />
Do
Pass what the page is about as scope. It shows as a locked chip, and
Clear, a saved view, and the URL leave it alone.
<RecordTable defaultView={{ query: { filters: [{ field: 'venue', operator: 'is', values: [venueId] }] } }} {...props} />
Don’t
Don't start the view with the page's own filter. People can remove it and see records the page isn't about, and saving the view keeps it.
viewActions={preset ? { onSaveAs } : { onSave, onSaveAs, onRename, onDelete }}
Do
Give a preset Save as new view alone. People make it their own without changing the screen's starting point for everyone.
viewActions={{ onSave: savePreset, onSaveAs }}
Don’t
Don't let one person save over a preset. Everyone who opens the screen then starts from their filters.
router.replace(`?${recordViewToSearchParams(view, position)}`)
Do
Write the whole view with its id, so a link or a reload opens the same view with the same changes, still marked as modified.
router.replace(`?view=${view.id}`)
Don’t
Don't write the id alone. A reload loses the changes without a word.
actions={[{ label: 'Export CSV', onAction: exportCsv },{ label: 'Print door list', onAction: printDoorList }]}
Do
Lead with the action people use most. It shows as a button, and the rest sit in the More actions menu at the end of the header.
<Records.Header><Menu>{/* More actions */}</Menu><Records.Search /><Button>Export CSV</Button></Records.Header>
Don’t
Don't put a menu before the search or other actions. The More menu always comes last in an action group.
<Records.Header searchPlaceholder='Search events' />
Do
Keep the standard header, or put Records.Options after the search in
your own. People sort, move, and hide columns there, and the view keeps
it.
<Menu><Menu.Trigger render={<Button>Columns</Button>} /><Menu.Content>{/* a checkbox per column */}</Menu.Content></Menu>
Don’t
Don't build a separate columns menu. It can't reorder, and its state never reaches the view or the URL.
bulkActions={[{label: 'Cancel',intent: 'danger',onAction: ({ selection, request }) => cancel(selection, request)}]}
Do
Act on the selection and request the action gets. They hold only records the search and filters still show, and "all matching" means the query.
onAction: () => cancel(mySelectedIds)
Don’t
Don't keep your own list of ids. It can hold records the search has since hidden, which people never confirmed.
<RecordTable loading={isLoading} error={failed} onRetry={refetch} {...props} />
Do
Pass loading, error, and onRetry. The table shows the matching
EmptyState, placeholders, and announcements.
{failed ? <p>Something went wrong</p> : <RecordTable {...props} />}
Don’t
Don't swap the table for your own message. The header and pager disappear, and screen readers hear nothing.
<RecordTable loadRange={async (request) => ({ rows, rowCount })} {...props} />
Do
Load by range when the list is long and people scroll it, open a record and come back. Return a total with each range, so the scrollbar is honest and a restored row lands as soon as the count arrives.
<RecordTable data={everyRecord} defaultPosition={{ pageSize: 100000 }} {...props} />
Don’t
Don't load every record up front to avoid paging. Use pages when people jump to a known page or the list is short.
caption names the table. Wide, every part reads as a table, with column headers and cells. Narrow, the records are a list named by the caption, each an item with its place in the whole list (aria-posinset and aria-setsize) when the list holds only some of them.aria-sort.maxHeight or fill), the box is the region, named "Shows, scrolls".Records.Status is a live region. It announces Loading, the error, and the result count while a search or filter narrows the list.aria-rowcount, counting every match (up to 300,000 loading by range), the header row, and the totals row when there is one, and each row its aria-rowindex, so screen readers announce "row 52 of 121". Loading by range, the count is -1 until the total is known.aria-busy while a range loads, placeholders are hidden from screen readers, and a failed range is announced, such as "Couldn't load more shows". The result count is announced only once the total is known. The header checkbox is named "Select loaded rows".aria-label. Suggestions open as you type, on a click, or with the arrows, not on Tab. While they're open, the rest of the page is hidden from screen readers, as for any combobox. Clear keeps focus in the field and keeps the page's scope. Keys lists what each key does.<.Records.Status also announces the selection, such as "3 selected, 24 results". It announces a search's count once typing settles, not on every keystroke, and against a server once the new count loads.aria-busy. A confirmation is an alert dialog.aria-busy.getRowHref holds a real link: it opens in a new tab with Cmd or Ctrl, and works with every other link convention, not just a click handler.function useRecords<Row extends object>(options: UseRecordsOptions<Row>): RecordsInstance<Row>
Holds the view, position, and selection, and searches, filters, sorts, and pages the records in the browser, or with rowCount leaves that to a server. Takes data (left out with loadRange), rowCount, rowCountExact (false when the count is a floor the server stopped at), fields, getRowId, view, defaultView, onViewChange, baseline (the saved view the screen opened), scope (the page's own filters), position, defaultPosition, onPositionChange, selectable, selection, defaultSelection, onSelectionChange, rowActions, getRowHref, recordName, loading, error, onRetry, timeZone (the reader's, which decides what "today" is), now (when relative dates resolve, by default when the list mounted in the browser; a server reads no clock, so a relative date filter shows the loading state until then), loadRange (which loads the list by range as people scroll, resolving with { rows, rowCount, rowCountExact }), and onRequestChange (which hears each request to fetch). Pass the result to Records or Records.Provider, which take only what useRecords returns, not an object built to its shape.
| Field | Type | Description |
|---|---|---|
mode | 'browser' | 'server' | 'range' | Where the records are searched and paged: server once rowCount is given, range once loadRange is. |
data | readonly Row[] | Every record held, before search, filters, and paging. In server mode, the page; in range mode, the records loaded, in order. |
fields | readonly RecordsField[] | What each record holds. |
view | RecordView | The search, filters, sort, and layout. |
appliedView | RecordView | The view the rows show, without the filters and sorts the fields can't apply, and without the page's scope. In the browser its search lags typing while a long list filters. Its query keeps its identity while its content holds. |
request | RecordsRequest | What to POST to a server: the applied query with relative dates fixed and the scope first, under version 1, with page and pageSize in server mode. It keeps its identity while its content holds, so key a fetch on it. |
rows | readonly { id: string; row: Row }[] | The current page. In range mode, every record loaded. |
resultCount | number | Records the search and filters match: rowCount in server mode. |
resultCountExact | boolean | False when resultCount is a floor the server stopped counting at, from rowCountExact. |
filtered | boolean | Whether a search or filter narrows the records. |
position | { page: number; pageSize: number; row: number } | The page, zero-based, and its size. In range mode, row is the first row on screen and each range is one page. |
pageCount | number | Pages at this size, at least one. |
setView | (view: RecordView) => void | Replaces the view. |
baseline | RecordView | undefined | The saved or preset view the screen opened. |
modified | boolean | Whether the view shows something other than baseline, by equalRecordViews. Always false without one. |
resetView | () => void | Goes back to baseline from the first page. Does nothing without one. |
skippedFilters | readonly number[] | Filters in the view, by index, that these fields can't apply. |
scope | readonly RecordFilter[] | The page's own filters, applied before the view's and never part of it. |
scopedQuery | RecordQuery | The applied view's query with the scope's filters first, which actions get as query. It keeps its identity while its content holds. |
setSearch | (search: string) => void | Sets the search and returns to the first page. In server mode, once typing pauses for 250ms, or at once when cleared. |
addFilter | (filter: RecordFilter) => void | Adds a filter. |
updateFilter | (index: number, filter: RecordFilter) => void | Replaces the filter at an index. |
removeFilter | (index: number) => void | Removes the filter at an index. |
clearQuery | () => void | Clears the search and filters, keeping the sort and layout. |
setSort | (sort: RecordSort[]) => void | Sets the sort and returns to the first page. |
setLayout | (layout: RecordLayout) => void | Sets the layout, such as the table's column order. |
setPage | (page: number) => void | Goes to a page, within the pages there are. |
setPageSize | (pageSize: number) => void | Sets the page size and returns to the first page. |
matchingRows | readonly { id: string; row: Row }[] | Every record the search and filters match, sorted, across pages. In server mode, only the page. |
selectable | boolean | Whether records can be selected. |
selection | RecordSelection | { ids }, or { allMatching: true, except }. |
selectedCount | number | Selected records the search and filters still match. In server mode, the ids picked, or rowCount less the exceptions. |
isSelected | (id: string) => boolean | Whether a record is selected. |
countSelection | (selection: RecordSelection) => number | How many records a selection holds that the search and filters still match. In server mode, the ids picked, or rowCount less the exceptions. |
setSelection | (selection: RecordSelection) => void | Replaces the selection. |
toggleRow | (id: string, options?: { range?: boolean }) => void | Picks or drops a record; range covers every record from the last one toggled, in sorted order. |
selectPage | (value: boolean) => void | Picks or drops every record on the page. |
selectAllMatching | () => void | Picks every record the search and filters match. |
clearSelection | () => void | Drops the selection. |
loading | boolean | Whether a load is under way. |
error | boolean | string | Whether the load failed, or its message. |
onRetry | (() => void) | undefined | Loads again. |
timeZone | string | The reader's zone in use. |
now | Date | The moment relative dates resolve against. |
Records in a table with the standard toolbar, pagination and status.
Every record, which Records filters, sorts and pages in the browser. With `rowCount`, the page the server returned. Leave it out with `loadRange`, which holds the records it loads.
How many records the search and filters match on the server. Giving it turns on server mode: `data` is one page, already searched, filtered, sorted and paged, and the search waits for a pause in typing. Fetch the `request` that `onRequestChange` hears. Once given, server mode stays: an `undefined` count keeps the last one. Pass 0 until the first count arrives. With `loadRange`, each range's result carries the count instead.
False when `rowCount` is a floor the server stopped counting at, such as a search index's 10,000: counts read "10,000+" and paging stops at the last page the server can reach. With `loadRange`, each range's result carries it instead.
Defaults to true.
What each record holds. Keep the array stable (module scope or `useMemo`).
Defaults to the record's index in `data`. Selecting in server or range mode needs it, as index ids repeat on every page and a refetch.
Gets the position too: a change to the search, filters or sort returns to the first page, and `onPositionChange` hears that first.
The saved or preset view the screen opened, so `modified` can tell when the view moves away from it and `resetView` can go back. Leave it out when no saved view is open.
Page, page size and, in range mode, the first row on screen: session state, never part of a view.
Defaults to { one: 'record', other: 'records' }.
Totals over every record the search and filters match, labelled "Totals for 1,234 records" by default. `true` or `'sum'` adds up the number and money fields in the browser, except shares, indexes and points. A server gives its own as `{ values }`, keyed by field, and `label`; keep passing the last ones while the next page loads. With `loadRange`, a range's result can carry them instead.
A fetch is under way. With no records the layout shows placeholders; with records it dims them.
The fetch failed. A string is shown as the message.
Shows a Retry button beside the error.
The viewer's IANA zone: it decides what "today" is and how timestamps read. Defaults to the browser's.
The moment relative dates resolve against. Defaults to when the list mounted in the browser: a server reads no clock, so a relative date filter shows the loading state until then. A new `now` that moves a relative date starts a range list over, so keep it stable.
Filters the page sets, such as the event a list of tickets belongs to. They filter like the view's but are never part of it, so they are never saved, cleared or edited: `Records.Search` shows them as locked chips. In server mode they reach the fetch through `scopedQuery`.
Lets people pick records for bulk actions.
Defaults to false.
Session state, like the position. A search or filter change drops picked records it hides.
Menu items for one record, such as `Menu.Item`s, shown in a menu on its row.
The record's page. Return `undefined` for a record with none.
Turns on range mode: one long list, searched, filtered and sorted on the server, that loads the records on screen as people scroll. Each gets the request for one range: one page of `pageSize` records from `start`, `end` exclusive. Resolve with its `rows` and, once known, `rowCount` (and `rowCountExact`). The list holds the rows for the request they answer, drops a reply for an older one, and starts over when the search, filters, sort, zone or a relative date's resolved dates change. Without a count, ranges load one after another until one comes back short. A rejection shows the range's error with Retry.
Hears `request` once the list is in the browser, then each time it changes: what to fetch in server mode, as the list resolved it.
Accessible name for the table.
Names the search field too, unless `searchLabel` is given.
Defaults to 'Search and filter'.
Names the search field when its placeholder doesn't.
The key that focuses the search, a `KeyboardEvent.key` value such as `/`, or `false` for none.
Defaults to '/'.
Defaults to [25, 50, 100].
Turns on selection and shows the bulk actions once something is selected.
Act on everything the search and filters match, with nothing selected, such as an export. Shown at the end of the toolbar.
Shows the open view's name, marked once it differs from `baseline`, with a menu to save, save as, rename, reset or delete it. The app keeps the views.
How the table shows its records under 40rem. Defaults to cards when a column is a `detail`, else list rows.
Scrolls the rows in their own box this tall, any CSS length.
Fills its parent's height and scrolls both ways inside, like `maxHeight`. The parent needs a definite height, such as `Pane.Body`.
Accessible name for the records, such as the table's.
The ways to show the records. Content shows the one the view names, or the first.
Actions on every matching record, with nothing selected, such as an export.
Act on everything the search and filters match. The first is a button and the rest a More actions menu; in a toolbar under 40rem, all are in the menu.
Overrides the records' `recordName`.
Shows the records in the layout the view names, or the first one given.
Scrolls the records in their own box this tall, any CSS length. The toolbar stays outside it.
Fills its parent's height and scrolls inside, like `maxHeight`. The parent needs a definite height, such as `Pane.Body`.
The records' controls, sticking above the content as it scrolls.
Replaces the standard controls: the search, the view actions, the options and the actions.
The standard search's placeholder.
Defaults to 'Search and filter'.
Names the standard search when its placeholder doesn't.
The key that focuses the standard search, a `KeyboardEvent.key` value such as `/`, or `false` for none.
Defaults to '/'.
The standard controls' actions on every matching record, at the end as `Records.Actions`.
The standard controls' view actions, after the search as `Records.ViewActions`.
A button that opens the view's options: the layout, the sort, and the shown layout's own settings, such as the table's columns. A popover, or a bottom drawer on a phone. Renders nothing when there is nothing to set.
Names the button, its tooltip and the panel.
Defaults to "Configure" and the layout, such as "Configure table".
Defaults to [25, 50, 100].
Shares records with parts spread across a layout, such as a `Pane`'s header, body and footer.
The ways to show the records. Content shows the one the view names, or the first.
Accessible name for the records, such as the table's.
Search and filter in one field. Typing searches the records' searchable fields and suggests filters, such as a city or "this weekend", that become chips. Selecting a chip edits its values in the list, and a date picked under is, is before, is after, or is between comes from a calendar there. The page's `scope` shows first, as locked chips.
Defaults to Search and filter.
Names the field. Defaults to the placeholder.
A key that focuses the search from anywhere on the page, as `QueryField`'s `focusShortcut` takes it, or `false` for none.
Defaults to /.
Select and Done, with Select all, for a layout that selects through Select mode. Renders nothing while the shown layout gives each record a checkbox.
Announces loading, errors, the result count and the selection to screen readers.
No additional props. It forwards all standard HTML attributes to the underlying element.
The open view's name, marked when it has changed, with a menu to save, save as, rename, reset or delete it. The app keeps the views: each action calls its handler, and Reset goes back to the records' `baseline`. Renders nothing with no baseline and no `onSaveAs`.
Saves the changes over the `baseline`: gets the view with the baseline's id and name. Leave it out for a view people can't change, such as a preset.
Saves the view as a new one: gets it with the name given and no id, for the app to assign.
Gets the `baseline` with its new name, leaving any changes unsaved.
Gets the `baseline` once people confirm. Open another view once it resolves.
A record's value as its field shows it: a status as a Badge, money and numbers in their format, dates in the house formats, and "Not available" when the record holds nothing.
The viewer's zone. Defaults to the browser's.