# RecordTable

> A table of records to search, filter, sort, and page through

Status: alpha. Added in Components 2.16.0. Updated in Components 2.17.0, 24 Oct 2026.

A table of records to search, filter, sort, and page through.

## Import

```tsx
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'`.

## Examples

### Default

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.

```tsx
const cities = ['Brisbane', 'Melbourne', 'Sydney', 'Perth', 'Hobart']
const names = ['Ocean Alley', 'Ball Park Music', 'Julia Jacklin', 'Angie McMahon', 'Alex Lahey', 'Middle Kids']
const shows = Array.from({ length: 240 }, (_, index) => ({
  id: `show-${index}`,
  show: `${names[index % names.length]} at Kazoo Hollow Room`,
  location: cities[index % cities.length],
  sold: (index * 37) % 2400,
  gross: (index * 7919) % 250000
}))

const field = recordFields()
const fields = [
  field.text('show', { label: 'Show' }),
  field.location('location', { label: 'Location' }),
  field.number('sold', { label: 'Sold' }),
  field.money('gross', { label: 'Gross', format: 'compactCurrency' })
]

const column = tableColumns(fields)
const columns = [
  column.field('show', { pin: true }),
  column.field('location'),
  column.field('sold'),
  column.field('gross')
]

render(
  <RecordTable
    caption='Upcoming shows'
    data={shows}
    fields={fields}
    columns={columns}
    getRowId={(row) => row.id}
  />
)
```

In TypeScript, give the builders your row type so every key is checked: `recordFields<Show>()` and `tableColumns<Show>(fields)`.

### States

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.

```tsx
const field = recordFields()
const fields = [field.text('show', { label: 'Show' }), field.location('location', { label: 'Location' }), field.number('sold', { label: 'Sold' })]
const column = tableColumns(fields)
const columns = [column.field('show', { pin: true }), column.field('location'), column.field('sold')]
const shows = [
  { id: 'a', show: 'Ocean Alley', location: 'Brisbane', sold: 1840 },
  { id: 'b', show: 'Alex Lahey', location: 'Melbourne', sold: 920 }
]
const props = {
  fields,
  columns,
  getRowId: (row) => row.id,
  recordName: { one: 'show', other: 'shows' }
}

render(
  <div className='grid gap-8'>
    <div className='grid gap-2'>
      <p className='text-sm text-subtle'>Loading, no records yet</p>
      <RecordTable {...props} caption='Loading shows' data={[]} loading defaultPosition={{ pageSize: 3 }} />
    </div>
    <div className='grid gap-2'>
      <p className='text-sm text-subtle'>Loading, with records</p>
      <RecordTable {...props} caption='Refreshing shows' data={shows} loading />
    </div>
    <div className='grid gap-2'>
      <p className='text-sm text-subtle'>Empty</p>
      <RecordTable {...props} caption='No shows' data={[]} />
    </div>
    <div className='grid gap-2'>
      <p className='text-sm text-subtle'>No match</p>
      <RecordTable {...props} caption='Unmatched shows' data={shows} defaultView={{ query: { search: 'Darwin' } }} />
    </div>
    <div className='grid gap-2'>
      <p className='text-sm text-subtle'>Error</p>
      <RecordTable {...props} caption='Failed shows' data={[]} error onRetry={() => {}} />
    </div>
  </div>
)
```

### Composition

`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)`.

```tsx
const layouts = [tableLayout(columns)]

function Shows() {
  const records = useRecords({ data, fields, getRowId: (row) => row.id })
  return (
    <Records records={records} layouts={layouts} caption='Upcoming shows'>
      <Records.Header searchPlaceholder='Search shows' />
      <Records.Content />
      <Records.Pagination pageSizes={[20, 40]} />
      <Records.Status />
    </Records>
  )
}
```

`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](#saved-views) 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.

### In a Pane

Most record tables live in a [`Pane`](https://ticketsolutionsptyltd.github.io/roadie/components/pane.md). 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](#narrow-layouts).

```tsx
const venues = [
  { value: 'kazoo-hollow-room', label: 'Kazoo Hollow Room', timeZone: 'Australia/Brisbane' },
  { value: 'quilted-walrus-room', label: 'The Quilted Walrus Room', timeZone: 'Australia/Melbourne' },
  { value: 'drongo-bell-social-club', label: 'Drongo Bell Social Club', timeZone: 'Australia/Perth' },
  { value: 'antler-kettle-hall', label: 'Antler Kettle Hall', timeZone: 'Australia/Sydney' }
]
const names = ['Paper Lanterns', 'Marzipan Thunderclap', 'Corduroy Lagoon Sessions', 'Sapphire Cassowary Night', 'Echo Garden', 'Midnight Frequency']
const statuses = ['on_sale', 'on_sale', 'selling_fast', 'sold_out', 'cancelled']
const events = Array.from({ length: 60 }, (_, index) => ({
  id: `event-${index}`,
  name: `${names[index % names.length]} ${Math.floor(index / names.length) + 1}`,
  venue: venues[index % venues.length].value,
  timeZone: venues[index % venues.length].timeZone,
  startsAt: new Date(Date.UTC(2026, 10, 6 + index, 9, 30)).toISOString(),
  status: statuses[index % statuses.length],
  sold: (index * 37) % 2400,
  gross: (index * 7919) % 250000
}))

const field = recordFields()
const fields = [
  field.text('name', { label: 'Event' }),
  { ...field.option('venue', { label: 'Venue', options: venues, searchable: true }), icon: <BuildingsIcon weight='bold' /> },
  field.date('startsAt', { label: 'Start date', moment: 'event', timeZoneKey: 'timeZone' }),
  field.option('status', {
    label: 'Status',
    status: {
      on_sale: { intent: 'success', order: 1 },
      selling_fast: { intent: 'warning', order: 2 },
      sold_out: { intent: 'neutral', order: 3 },
      cancelled: { intent: 'danger', order: 4 }
    }
  }),
  field.number('sold', { label: 'Sold' }),
  field.money('gross', { label: 'Gross', format: 'compactCurrency' })
]

const column = tableColumns(fields)
const layouts = [
  tableLayout([
    column.field('name', { pin: true, narrow: 'title' }),
    column.field('startsAt', { narrow: 'detail' }),
    column.field('venue', { narrow: 'description' }),
    column.field('status', { narrow: 'trailing' }),
    column.field('sold', { narrow: 'detail' }),
    column.field('gross', { narrow: 'detail' })
  ])
]

function EventsPane() {
  const records = useRecords({
    data: events,
    fields,
    getRowId: (row) => row.id,
    selectable: true,
    recordName: { one: 'event', other: 'events' },
    defaultView: { query: { sort: [{ field: 'startsAt', direction: 'ascending' }] } }
  })
  return (
    <Records.Provider records={records} layouts={layouts} caption='Events'>
      <Pane className='h-full'>
        <Pane.Header>
          <Pane.Title>Events</Pane.Title>
        </Pane.Header>
        <Pane.Body>
          <Records.Header searchPlaceholder='Search events' />
          <Records.Content />
          <Records.Status />
          <Records.BulkActions
            actions={[
              { label: 'Export', icon: <ExportIcon weight='bold' />, onAction: ({ selection }) => console.log('export', selection) },
              { label: 'Cancel', icon: <XCircleIcon weight='bold' />, intent: 'danger', onAction: ({ selection }) => console.log('cancel', selection) }
            ]}
          />
        </Pane.Body>
        <Pane.Footer>
          <Records.Pagination />
        </Pane.Footer>
      </Pane>
    </Records.Provider>
  )
}

render(
  <div className='grid grid-cols-1 gap-4'>
    <div className='h-144 rounded-2xl bg-sunken p-3'>
      <EventsPane />
    </div>
    <div className='mx-auto h-176 w-97.5 max-w-full rounded-4xl bg-sunken p-3'>
      <EventsPane />
    </div>
  </div>
)
```

### Fields and columns

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](#column-priority).
- `narrow` to place the column when the table turns into list rows or cards. See [Narrow layouts](#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.

```tsx
const field = recordFields()
const fields = [
  field.text('number', { label: 'Order' }),
  field.text('customer', { label: 'Customer' }),
  field.option('status', {
    label: 'Status',
    status: { paid: { intent: 'success' }, pending: { intent: 'warning' }, refunded: { intent: 'neutral', label: 'Refunded in full' } }
  }),
  field.date('placedAt', { label: 'Placed', moment: 'timestamp' }),
  field.boolean('gift', { label: 'Gift' }),
  field.money('total', { label: 'Total', format: 'currency' })
]
const column = tableColumns(fields)
const columns = [
  column.field('number', {
    pin: true,
    cell: ({ value }) => <span className='font-mono font-semibold text-strong'>{value}</span>
  }),
  column.field('customer'),
  column.field('status'),
  column.field('placedAt'),
  column.field('gift'),
  column.field('total')
]
const orders = [
  { id: 'a', number: 'OZ-48210', customer: 'Mia Tran', status: 'paid', placedAt: '2026-10-01T09:15:00Z', gift: false, total: 149 },
  { id: 'b', number: 'OZ-48217', customer: 'Jack Ellis', status: 'pending', placedAt: '2026-10-01T22:40:00Z', gift: true, total: 79.9 },
  { id: 'c', number: 'OZ-48224', customer: 'Ava Nguyen', status: 'refunded', placedAt: '2026-10-02T03:05:00Z', gift: false, total: null }
]

render(
  <RecordTable
    caption='Orders'
    data={orders}
    fields={fields}
    columns={columns}
    getRowId={(row) => row.id}
    defaultView={{ layout: { type: 'table', columns: { order: ['total'], hidden: ['gift'] } } }}
  />
)
```

### Search and filters

The header's search is a [query field](https://ticketsolutionsptyltd.github.io/roadie/components/query-field.md) 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.

```tsx
const field = recordFields()
const places = [
  { value: 'au', label: 'Australia', kind: 'country' },
  { value: 'au-qld', label: 'Queensland', kind: 'state', parent: 'au' },
  { value: 'au-qld-brisbane', label: 'Brisbane', kind: 'city', parent: 'au-qld' },
  { value: 'au-qld-fortitude-valley', label: 'Fortitude Valley', kind: 'suburb', parent: 'au-qld-brisbane' },
  { value: 'au-qld-west-end', label: 'West End', kind: 'suburb', parent: 'au-qld-brisbane' },
  { value: 'au-vic', label: 'Victoria', kind: 'state', parent: 'au' },
  { value: 'au-vic-melbourne', label: 'Melbourne', kind: 'city', parent: 'au-vic' },
  { value: 'au-vic-fitzroy', label: 'Fitzroy', kind: 'suburb', parent: 'au-vic-melbourne' },
  { value: 'au-vic-st-kilda', label: 'St Kilda', kind: 'suburb', parent: 'au-vic-melbourne' }
]
const fields = [
  field.text('show', { label: 'Show' }),
  field.location('location', { label: 'Location', options: places }),
  { ...field.option('venue', { label: 'Venue' }), icon: <BuildingsIcon weight='bold' /> },
  field.option('status', {
    label: 'Status',
    status: { on_sale: { intent: 'success', order: 1 }, selling_fast: { intent: 'warning', order: 2 }, sold_out: { intent: 'danger', order: 3 } }
  }),
  field.date('starts', { label: 'Start date', moment: 'date' }),
  field.number('sold', { label: 'Sold' })
]
const column = tableColumns(fields)
const columns = [
  column.field('show', { pin: true }),
  column.field('location'),
  column.field('venue'),
  column.field('status'),
  column.field('starts'),
  column.field('sold')
]
const bands = ['Ocean Alley', 'Ball Park Music', 'Julia Jacklin', 'Angie McMahon', 'Alex Lahey', 'Middle Kids']
const venues = [
  ['au-qld-fortitude-valley', 'Kazoo Hollow Room'],
  ['au-vic-fitzroy', 'Echidna Gaslight Theatre'],
  ['au-qld-west-end', 'Iguana Teapot Hall'],
  ['au-vic-st-kilda', 'Saltbush Trumpet Ballroom']
]
const statuses = ['on_sale', 'selling_fast', 'sold_out']
const today = plainDateOf(new Date(), viewerTimeZone())
const shows = Array.from({ length: 60 }, (_, index) => ({
  id: `show-${index}`,
  show: bands[index % bands.length],
  location: venues[index % venues.length][0],
  venue: venues[index % venues.length][1],
  status: statuses[index % statuses.length],
  starts: addDays(today, index % 45),
  sold: (index * 137) % 2400
}))

render(
  <RecordTable
    caption='Upcoming shows'
    data={shows}
    fields={fields}
    columns={columns}
    getRowId={(row) => row.id}
    defaultView={{
      query: {
        filters: [
          { field: 'status', operator: 'is', values: ['on_sale', 'selling_fast'] },
          { field: 'location', operator: 'is', values: ['au-qld-brisbane'] }
        ]
      }
    }}
    defaultPosition={{ pageSize: 10 }}
  />
)
```

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](https://ticketsolutionsptyltd.github.io/roadie/foundations/records.md#places) covers how they nest.

#### Searching

Typing searches the searchable fields, and suggests the filters the text reads:

- an option's or location's values, or a status
- a boolean, by its name
- an identifier, by its `match`
- a date phrase for each date field, such as "this weekend" or "after 1 dec"

Pick 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".

#### Picking a field

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.

#### Typing a value

- Typing narrows an option, location, boolean, or date field's values, and Enter takes the first.
- Text or a number you type shows as one row worded as its chip, such as "Sold is more than 1,500", and Enter takes it.
- Between reads 20 and 50, 20-50, 20 to 50, or 20..50, in either order. A space after the first number adds the "and", and one Backspace takes it away.
- Money takes a `$` and thousands commas, such as $1,500.
- Until the value reads, the list says what to type, such as "Type a number." or "Between 20 and what? Type the second number."
- Under **is empty** or **is not empty**, the list shows one row, such as "Location is empty", and Enter takes it. What you'd typed is set aside, and comes back if you move to another operator, or press Backspace in the empty field, which also returns to the operator you typed it under. Typing switches back to the field's first operator, such as **is**, so what you type applies.

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.

#### Editing a chip

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.

- **An option or location chip.** Its values stay in the dashed chip, and the field is empty. Take values to tick and untick them, and the list stays open for more. Unticking the last value removes the chip. Enter on typed text only ticks. Press **is**, **is not**, or **has all of** (**is in** or **is not in** on a location) to change the condition and keep the values. A chip changed to **is** joins the field's is chip.
- **A text or number chip.** Its value is in the field and its condition in the toggle, such as 20 and 50 under **between**. Change either, and Enter replaces the chip once the value reads and ends the edit.
- **A date chip.** Its dates are in the field, such as 12 Nov to 15 Dec under **is between**, or This week under **is relative**. Type others, or take a quick date or a day from the calendar, to replace them and end the edit.
- **A boolean chip.** The field is empty. Yes or No replaces what the chip holds and ends the edit.
- **Is empty or is not empty.** The row under either replaces what the chip holds, and ends the edit of any chip but an option chip. A chip that already reads is empty or is not empty opens with that row ticked.

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.

#### Dates

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.

#### Chips

- A chip with more values than it shows names them all in its tooltip.
- A relative date chip, such as "Start date is this weekend", shows its dates in its tooltip.
- A filter these fields can't apply shows in warning colours and filters nothing.

#### Keys

| 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`.

#### Scope

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.

```tsx
const field = recordFields()
const fields = [
  field.text('show', { label: 'Show' }),
  { ...field.option('venue', { label: 'Venue' }), icon: <BuildingsIcon weight='bold' /> },
  field.number('sold', { label: 'Sold' })
]
const column = tableColumns(fields)
const columns = [column.field('show', { pin: true }), column.field('venue'), column.field('sold')]
const venues = ['Kazoo Hollow Room', 'Opal Harpoon Room', 'Antler Kettle Hall']
const bands = ['Ocean Alley', 'Ball Park Music', 'Julia Jacklin', 'Angie McMahon']
const shows = Array.from({ length: 24 }, (_, index) => ({
  id: `show-${index}`,
  show: bands[index % bands.length],
  venue: venues[index % venues.length],
  sold: (index * 211) % 1800
}))

render(
  <RecordTable
    caption='Shows at Kazoo Hollow Room'
    data={shows}
    fields={fields}
    columns={columns}
    getRowId={(row) => row.id}
    scope={[{ field: 'venue', operator: 'is', values: ['Kazoo Hollow Room'] }]}
  />
)
```

### Views

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](https://ticketsolutionsptyltd.github.io/roadie/foundations/records.md) 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.

```tsx
const field = recordFields()
const fields = [
  field.text('show', { label: 'Show' }),
  field.location('location', { label: 'Location' }),
  field.number('sold', { label: 'Sold' })
]
const column = tableColumns(fields)
const columns = [column.field('show', { pin: true }), column.field('location'), column.field('sold')]
const cities = ['Brisbane', 'Melbourne', 'Sydney', 'Perth', 'Hobart']
const shows = Array.from({ length: 40 }, (_, index) => ({
  id: `show-${index}`,
  show: `Middle Kids ${index + 1}`,
  location: cities[index % cities.length],
  sold: (index * 37) % 2400
}))

function Controlled() {
  const [view, setView] = useState({
    query: { search: '', filters: [{ field: 'location', operator: 'is', values: ['Perth'] }], sort: [{ field: 'sold', direction: 'descending' }] },
    layout: { type: 'table' }
  })
  return (
    <div className='grid gap-3'>
      <p className='text-sm text-subtle'>URL: ?{recordViewToSearchParams(view).toString()}</p>
      <RecordTable
        caption='Shows'
        data={shows}
        fields={fields}
        columns={columns}
        getRowId={(row) => row.id}
        view={view}
        onViewChange={setView}
      />
    </div>
  )
}

render(<Controlled />)
```

### Options

`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.

- **Sort** lists each level the view sorts by: a field, then its direction in the field's own terms, such as A to Z, Low to high, or Earliest first. Add sort starts with the first sortable field, its natural way, and Add another sort breaks ties with a field not sorted yet. A header click still sorts by that column alone. A level on a field that doesn't sort isn't listed, and changes here leave it in the view. A level that repeats a field sorts nothing, so changes here drop it.
- **Columns** lists the table's columns in their order. Drag a handle to move a column, or click it, or press Enter on it, for a Move menu. The eye beside each column shows or hides it, and a hidden column stays in the list, dimmed, in its place. Pinned columns always come first and always show, so the list names them rather than offering them. The last shown column can't be hidden.

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.

```tsx
const field = recordFields()
const fields = [
  field.text('name', { label: 'Event' }),
  field.location('location', { label: 'Location' }),
  field.date('startsAt', { label: 'Start date', moment: 'date' }),
  field.number('sold', { label: 'Sold' }),
  field.money('gross', { label: 'Gross', format: 'compactCurrency' })
]
const column = tableColumns(fields)
const columns = [
  column.field('name', { pin: true }),
  column.field('location'),
  column.field('startsAt'),
  column.field('sold'),
  column.field('gross')
]
const names = ['Nectarine Hoedown', 'Lampshade Disco', 'Ember Galah Ball', 'Raffia Spaceship']
const cities = ['Brisbane', 'Melbourne', 'Sydney', 'Perth', 'Hobart']
const events = Array.from({ length: 12 }, (_, index) => ({
  id: `event-${index}`,
  name: `${names[index % names.length]} ${index + 1}`,
  location: cities[index % cities.length],
  startsAt: `2026-11-${String(index + 2).padStart(2, '0')}`,
  sold: (index * 37) % 2400,
  gross: (index * 7919) % 250000
}))

function Configured() {
  const [view, setView] = useState({
    query: { search: '', filters: [], sort: [{ field: 'startsAt', direction: 'ascending' }] },
    layout: { type: 'table', columns: { hidden: ['gross'] } }
  })
  return (
    <div className='grid gap-3'>
      <p className='text-sm text-subtle'>URL: ?{recordViewToSearchParams(view).toString()}</p>
      <RecordTable
        caption='Events'
        data={events}
        fields={fields}
        columns={columns}
        getRowId={(row) => row.id}
        view={view}
        onViewChange={setView}
      />
    </div>
  )
}

render(<Configured />)
```

`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.

#### Layouts

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](https://ticketsolutionsptyltd.github.io/roadie/components/record-grid.md#layouts) for an example.

### Saved views

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:

- **Save view** calls `onSave` with the view under the baseline's id and name. It shows while the view is modified.
- **Reset view** goes back to the baseline. It shows while the view is modified, and needs no handler.
- **Save as new view** asks for a name, then calls `onSaveAs` with the view, that name, and no id, for your app to assign one.
- **Rename view** asks for a new name, then calls `onRename` with the baseline under it. Unsaved changes stay unsaved.
- **Delete view** asks first, then calls `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:

```tsx
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)
```

#### Views in a Pane

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.

```tsx
const venues = ['Kazoo Hollow Room', 'The Quilted Walrus Room', 'Drongo Bell Social Club', 'Antler Kettle Hall']
const cities = ['Brisbane', 'Melbourne', 'Perth', 'Sydney']
const names = ['Nectarine Hoedown', 'Lampshade Disco', 'Ember Galah Ball', 'Raffia Spaceship', 'Sapphire Cassowary Night', 'Tuxedo Possum Cabaret']
const events = Array.from({ length: 48 }, (_, index) => ({
  id: `event-${index}`,
  name: `${names[index % names.length]} ${Math.floor(index / names.length) + 1}`,
  venue: venues[index % venues.length],
  location: cities[index % cities.length],
  startsAt: `2026-${10 + (index % 3)}-${String((index % 27) + 1).padStart(2, '0')}`,
  status: index % 5 === 0 ? 'sold_out' : 'on_sale',
  sold: (index * 37) % 2400
}))

const field = recordFields()
const fields = [
  field.text('name', { label: 'Event' }),
  field.text('venue', { label: 'Venue' }),
  field.location('location', { label: 'Location' }),
  field.date('startsAt', { label: 'Start date', moment: 'date' }),
  field.option('status', {
    label: 'Status',
    status: { on_sale: { intent: 'success', label: 'On sale' }, sold_out: { intent: 'neutral', label: 'Sold out' } }
  }),
  field.number('sold', { label: 'Sold' })
]
const column = tableColumns(fields)
const layouts = [
  tableLayout([
    column.field('name', { pin: true }),
    column.field('startsAt'),
    column.field('venue'),
    column.field('location'),
    column.field('status'),
    column.field('sold')
  ])
]

const soonest = [{ field: 'startsAt', direction: 'ascending' }]
const presets = [
  { id: 'all', name: 'All events', query: { search: '', filters: [], sort: soonest }, layout: { type: 'table' } },
  {
    id: 'on-sale',
    name: 'On sale',
    query: { search: '', filters: [{ field: 'status', operator: 'is', values: ['on_sale'] }], sort: soonest },
    layout: { type: 'table', columns: { hidden: ['status'] } }
  },
  {
    id: 'sold-out',
    name: 'Sold out',
    query: { search: '', filters: [{ field: 'status', operator: 'is', values: ['sold_out'] }], sort: [{ field: 'sold', direction: 'descending' }] },
    layout: { type: 'table' }
  }
]

function ViewPane({ baseline, preset, onSave, onSaveAs, onRename, onDelete }) {
  const [view, setView] = useState(baseline)
  const [position, setPosition] = useState({})
  const [opened, setOpened] = useState(baseline.id)
  // Another view opens in the same pane, so focus stays on the view button.
  if (opened !== baseline.id) {
    setOpened(baseline.id)
    setView(baseline)
    setPosition({})
  }
  const records = useRecords({
    data: events,
    fields,
    getRowId: (row) => row.id,
    view,
    onViewChange: setView,
    position,
    onPositionChange: setPosition,
    baseline,
    recordName: { one: 'event', other: 'events' }
  })
  return (
    <Records.Provider records={records} layouts={layouts} caption={baseline.name}>
      <Pane className='h-full'>
        <Pane.Header>
          <Pane.Title>{baseline.name}</Pane.Title>
        </Pane.Header>
        <Pane.Body>
          <Records.Header
            searchPlaceholder='Search events'
            viewActions={preset ? { onSaveAs } : { onSave, onSaveAs, onRename, onDelete }}
          />
          <Records.Content />
          <Records.Status />
        </Pane.Body>
        <Pane.Footer>
          <Records.Pagination />
        </Pane.Footer>
      </Pane>
    </Records.Provider>
  )
}

function EventViews() {
  const [mine, setMine] = useState([])
  const replace = (saved) => setMine((views) => views.map((held) => (held.id === saved.id ? saved : held)))
  const item = (view) => (
    <Navigator.Item key={view.id} value={`/events/${view.id}`} href={`/events/${view.id}`}>
      {view.name}
    </Navigator.Item>
  )
  return (
    <DemoRouter initialPath='/events/all'>
      {(path, navigate) => {
        const id = path.split('/')[2] ?? 'all'
        const preset = presets.find((view) => view.id === id)
        const baseline = preset ?? mine.find((view) => view.id === id) ?? presets[0]
        return (
          <Navigator value={path} className='h-full'>
            <Navigator.Primary aria-label='Organiser'>
              <Navigator.Brand href='/events/all' />
              <Navigator.Item value='/events' href='/events' icon={<TicketIcon />}>
                Events
                <Navigator.Secondary aria-label='Event views'>
                  <Navigator.Group>
                    <Navigator.GroupTitle>Views</Navigator.GroupTitle>
                    {presets.map(item)}
                  </Navigator.Group>
                  {mine.length > 0 && (
                    <Navigator.Group>
                      <Navigator.GroupTitle>Your views</Navigator.GroupTitle>
                      {mine.map(item)}
                    </Navigator.Group>
                  )}
                </Navigator.Secondary>
              </Navigator.Item>
            </Navigator.Primary>
            <ViewPane
              baseline={baseline}
              preset={Boolean(preset)}
              onSave={replace}
              onRename={replace}
              onSaveAs={(view) => {
                const saved = { ...view, id: `mine-${mine.length + 1}-${Date.now()}` }
                setMine((views) => [...views, saved])
                navigate(`/events/${saved.id}`)
              }}
              onDelete={(view) => {
                setMine((views) => views.filter((held) => held.id !== view.id))
                navigate('/events/all')
              }}
            />
          </Navigator>
        )
      }}
    </DemoRouter>
  )
}

render(
  <div className='h-160 overflow-hidden rounded-2xl border border-subtle'>
    <EventViews />
  </div>
)
```

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.

### Selection and actions

`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`.

```tsx
const cities = ['Brisbane', 'Melbourne', 'Sydney', 'Perth', 'Hobart']
const names = ['Ocean Alley', 'Ball Park Music', 'Julia Jacklin', 'Angie McMahon', 'Alex Lahey', 'Middle Kids']
const shows = Array.from({ length: 40 }, (_, index) => ({
  id: `show-${index}`,
  show: `${names[index % names.length]} at Kazoo Hollow Room`,
  location: cities[index % cities.length],
  sold: (index * 37) % 2400,
  gross: (index * 7919) % 250000
}))

const field = recordFields()
const fields = [
  field.text('show', { label: 'Show' }),
  field.location('location', { label: 'Location' }),
  field.number('sold', { label: 'Sold' }),
  field.money('gross', { label: 'Gross', format: 'compactCurrency' })
]
const column = tableColumns(fields)
const columns = [column.field('show', { pin: true }), column.field('location'), column.field('sold'), column.field('gross')]

function ShowActions() {
  return (
    <DemoRouter initialPath='/shows'>
      {(path) => (
        <div className='grid gap-2'>
          <p className='text-sm text-subtle'>Current route: {path}</p>
          <RecordTable
            caption='Upcoming shows'
            data={shows}
            fields={fields}
            columns={columns}
            getRowId={(row) => row.id}
            getRowHref={(row) => `/shows/${row.id}`}
            rowActions={(row) => (
              <>
                <Menu.Item icon={<PencilSimpleIcon weight='bold' />} onClick={() => console.log('edit', row.id)}>Edit</Menu.Item>
                <Menu.Item icon={<CopyIcon weight='bold' />} onClick={() => console.log('duplicate', row.id)}>Duplicate</Menu.Item>
              </>
            )}
            bulkActions={[
              { label: 'Export', icon: <ExportIcon weight='bold' />, onAction: ({ selection }) => console.log('export', selection) },
              {
                label: 'Cancel',
                icon: <XCircleIcon weight='bold' />,
                intent: 'danger',
                confirm: { description: 'Ticket holders are refunded and told by email.' },
                onAction: ({ selection }) => console.log('cancel', selection)
              }
            ]}
            recordName={{ one: 'show', other: 'shows' }}
          />
        </div>
      )}
    </DemoRouter>
  )
}

render(<ShowActions />)
```

### Table actions and export

`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.

```tsx
const names = ['Mia Tran', 'Jack Ellis', 'Ava Nguyen']
const tickets = [
  { value: 'ga', label: 'General admission' },
  { value: 'vip', label: 'VIP' },
  { value: 'early', label: 'Early bird' }
]
const attendees = Array.from({ length: 60 }, (_, index) => ({
  id: `attendee-${index}`,
  name: `${names[index % names.length]} ${Math.floor(index / names.length) + 1}`,
  ticket: tickets[index % tickets.length].value,
  order: `OZ-${48210 + index * 7}`,
  paid: [79.9, 149, 59][index % tickets.length]
}))

const field = recordFields()
const fields = [
  field.text('name', { label: 'Name' }),
  field.option('ticket', { label: 'Ticket type', options: tickets }),
  field.text('order', { label: 'Order' }),
  field.money('paid', { label: 'Paid' })
]
const column = tableColumns(fields)
const columns = fields.map((field, index) => column.field(field.key, { pin: index === 0 }))
const exportCsv = (records) =>
  downloadCsv(
    recordsToCsv(
      records.matchingRows.map(({ row }) => row),
      shownColumns(columns, records.view.layout).map(({ field }) => field),
      { timeZone: records.timeZone }
    ),
    'attendees.csv'
  )

render(
  <RecordTable
    caption='Attendees'
    data={attendees}
    fields={fields}
    columns={columns}
    getRowId={(row) => row.id}
    recordName={{ one: 'attendee', other: 'attendees' }}
    actions={[
      {
        label: 'Export CSV',
        icon: <DownloadIcon weight='bold' />,
        onAction: ({ records }) => exportCsv(records)
      },
      { label: 'Print door list', onAction: ({ query }) => console.log('print', query) }
    ]}
  />
)
```

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](https://ticketsolutionsptyltd.github.io/roadie/foundations/records.md#server-contract) has the request's shape. Have the server send the CSV without a byte order mark, since `downloadCsv` adds one.

```tsx
actions={[
  {
    label: 'Export CSV',
    onAction: async ({ request }) => {
      const response = await fetch('/api/attendees/export', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(request)
      })
      downloadCsv(await response.text(), 'attendees.csv')
    }
  }
]}
```

### Totals

`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.

```tsx
const field = recordFields()
const fields = [
  field.text('show', { label: 'Show' }),
  field.text('venue', { label: 'Venue' }),
  field.number('capacity', { label: 'Capacity' }),
  field.number('sold', { label: 'Sold' }),
  field.money('gross', { label: 'Gross', format: 'compactCurrency' })
]
const column = tableColumns(fields)
const columns = [
  column.field('show', { pin: true, narrow: 'title' }),
  column.field('venue', { narrow: 'description' }),
  column.field('capacity', { priority: 2 }),
  column.field('sold', { narrow: 'trailing' }),
  column.field('gross')
]
const names = ['Ochre Kite Weekender', 'Lampshade Disco', 'Raffia Spaceship', 'Nectarine Hoedown']
const venues = ['Kazoo Hollow Room', 'Antler Kettle Hall', 'Opal Harpoon Room']
const shows = Array.from({ length: 60 }, (_, index) => ({
  id: `show-${index}`,
  show: `${names[index % names.length]} ${Math.floor(index / names.length) + 1}`,
  venue: venues[index % venues.length],
  capacity: [800, 1200, 2400][index % 3],
  sold: (index * 37) % 800,
  gross: ((index * 37) % 800) * 64.9
}))

render(
  <RecordTable
    caption='Shows'
    data={shows}
    fields={fields}
    columns={columns}
    getRowId={(row) => row.id}
    recordName={{ one: 'show', other: 'shows' }}
    defaultPosition={{ pageSize: 10 }}
    totals
    bulkActions={[{ label: 'Export', onAction: () => {} }]}
  />
)
```

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.

```tsx
<RecordTable
  totals={{
    values: {
      sold: sums.sold,
      gross: sums.gross,
      soldShare: sums.sold / sums.capacity
    }
  }}
/>
```

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.

### Server data

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.

```tsx
const venues = ['Kazoo Hollow Room', 'Antler Kettle Hall', 'Opal Harpoon Room', 'Saltbush Trumpet Ballroom']
const names = ['Ochre Kite Weekender', 'Lampshade Disco', 'Raffia Spaceship', 'Nectarine Hoedown', 'Tuxedo Possum Cabaret', 'Marzipan Thunderclap']
const cities = ['Brisbane', 'Melbourne', 'Sydney', 'Perth', 'Hobart']
const everyShow = Array.from({ length: 400 }, (_, index) => ({
  id: `show-${index}`,
  show: `${names[index % names.length]} ${Math.floor(index / names.length) + 1}`,
  venue: venues[index % venues.length],
  location: cities[index % cities.length],
  status: ['on_sale', 'on_sale', 'sold_out', 'cancelled'][index % 4],
  sold: (index * 37) % 2400
}))

const field = recordFields()
const fields = [
  field.text('show', { label: 'Show' }),
  field.text('venue', { label: 'Venue' }),
  field.location('location', { label: 'Location' }),
  field.option('status', {
    label: 'Status',
    status: {
      on_sale: { intent: 'success', label: 'On sale' },
      sold_out: { intent: 'neutral', label: 'Sold out' },
      cancelled: { intent: 'danger', label: 'Cancelled' }
    }
  }),
  field.number('sold', { label: 'Sold' })
]
const column = tableColumns(fields)
const columns = fields.map((field, index) => column.field(field.key, { pin: index === 0 }))
const timeZone = 'Australia/Sydney'

function pretendServer(request) {
  const { query, page, pageSize } = request
  const matching = sortRecords(everyShow.filter(compileRecordQuery(query, fields)), query.sort, fields, { timeZone: query.timeZone })
  const start = page * pageSize
  return new Promise((resolve) =>
    setTimeout(
      () =>
        resolve({
          rows: matching.slice(start, start + pageSize),
          rowCount: matching.length,
          totals: { values: { sold: matching.reduce((sum, show) => sum + show.sold, 0) } },
          request
        }),
      500
    )
  )
}

const firstView = { query: { search: '', filters: [], sort: [] }, layout: { type: 'table' } }

function ServerShows() {
  const [request, setRequest] = useState(null)
  const [result, setResult] = useState({ rows: [], rowCount: 0, request: null })

  useEffect(() => {
    if (!request) return
    let current = true
    pretendServer(request).then((next) => {
      if (current) setResult(next)
    })
    return () => {
      current = false
    }
  }, [request])

  return (
    <div className='grid gap-3'>
      <RecordTable
        caption='Shows'
        data={result.rows}
        rowCount={result.rowCount}
        totals={result.totals}
        loading={!result.request || result.request !== request}
        fields={fields}
        columns={columns}
        getRowId={(row) => row.id}
        recordName={{ one: 'show', other: 'shows' }}
        defaultPosition={{ pageSize: 25 }}
        onRequestChange={setRequest}
        timeZone={timeZone}
        bulkActions={[{ label: 'Export', onAction: ({ selection, request }) => console.log('export', selection, request) }]}
      />
      {result.request && (
        <pre className='rounded-md bg-sunken p-3 font-mono text-xs break-all whitespace-pre-wrap text-subtle'>
          {JSON.stringify(result.request)}
        </pre>
      )}
    </div>
  )
}

render(<ServerShows />)
```

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](https://ticketsolutionsptyltd.github.io/roadie/foundations/records.md) lists the index shape it needs.

```tsx
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.

### Range loading

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.

```tsx
const names = ['Ochre Kite Weekender', 'Lampshade Disco', 'Raffia Spaceship', 'Nectarine Hoedown', 'Tuxedo Possum Cabaret', 'Marzipan Thunderclap']
const cities = ['Brisbane', 'Melbourne', 'Sydney', 'Perth', 'Hobart']
const everyShow = Array.from({ length: 5000 }, (_, index) => ({
  id: `show-${index}`,
  show: `${names[index % names.length]} ${Math.floor(index / names.length) + 1}`,
  location: cities[index % cities.length],
  sold: (index * 37) % 2400
}))

const field = recordFields()
const fields = [
  field.text('show', { label: 'Show' }),
  field.location('location', { label: 'Location' }),
  field.number('sold', { label: 'Sold' })
]
const column = tableColumns(fields)
const columns = [column.field('show', { pin: true }), column.field('location'), column.field('sold')]
const timeZone = 'Australia/Sydney'
const pageSize = 50

function pretendServer(request) {
  const { query, start, end } = request
  const matching = sortRecords(everyShow.filter(compileRecordQuery(query, fields)), query.sort, fields, { timeZone: query.timeZone })
  return new Promise((resolve) =>
    setTimeout(() => resolve({ rows: matching.slice(start, end), rowCount: matching.length }), 400)
  )
}

function RangeShows() {
  const [request, setRequest] = useState(null)

  return (
    <div className='grid gap-3'>
      <RecordTable
        caption='Shows'
        fields={fields}
        columns={columns}
        getRowId={(row) => row.id}
        recordName={{ one: 'show', other: 'shows' }}
        defaultPosition={{ pageSize }}
        timeZone={timeZone}
        loadRange={(next) => {
          setRequest(next)
          return pretendServer(next)
        }}
        maxHeight='30rem'
      />
      {request && (
        <pre className='rounded-md bg-sunken p-3 font-mono text-xs break-all whitespace-pre-wrap text-subtle'>
          {JSON.stringify(request)}
        </pre>
      )}
    </div>
  )
}

render(<RangeShows />)
```

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.

### Long lists

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`.

```tsx
const field = recordFields()
const fields = [
  field.text('show', { label: 'Show' }),
  field.text('venue', { label: 'Venue' }),
  field.location('location', { label: 'Location' }),
  field.number('sold', { label: 'Sold' }),
  field.number('capacity', { label: 'Capacity' }),
  field.money('gross', { label: 'Gross', format: 'compactCurrency' })
]
const column = tableColumns(fields)
const columns = [
  column.field('show', { pin: true, width: { min: 14 } }),
  column.field('venue', { width: { min: 14 } }),
  column.field('location', { width: { min: 10 } }),
  column.field('sold', { width: { min: 8 } }),
  column.field('capacity', { width: { min: 8 } }),
  column.field('gross', { width: { min: 8 } })
]
const cities = ['Brisbane', 'Melbourne', 'Sydney', 'Perth', 'Hobart']
const shows = Array.from({ length: 100 }, (_, index) => ({
  id: `show-${index}`,
  show: `Julia Jacklin ${index + 1}`,
  venue: 'The Quilted Walrus Room',
  location: cities[index % cities.length],
  sold: (index * 37) % 2400,
  capacity: 2400,
  gross: (index * 7919) % 250000
}))

render(
  <RecordTable
    caption='Shows'
    data={shows}
    fields={fields}
    columns={columns}
    getRowId={(row) => row.id}
    defaultPosition={{ pageSize: 100 }}
    maxHeight='24rem'
  />
)
```

### Column priority

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.

```tsx
const columns = [
  column.field('name', { pin: true, narrow: 'title' }),
  column.field('venue', { priority: 1 }),
  column.field('capacity', { priority: 2 }),
  column.field('updated', { priority: 3 })
]
```

### Narrow layouts

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.

#### List rows

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.

```tsx
const venues = ['Kazoo Hollow Room', 'Antler Kettle Hall', 'Echidna Gaslight Theatre', 'Foxglove Tin Pavilion']
const names = ['Marzipan Thunderclap', 'Ember Galah Ball', 'Nectarine Hoedown', 'Tuxedo Possum Cabaret', 'Lampshade Disco']
const statuses = ['on-sale', 'selling-fast', 'sold-out']
const events = Array.from({ length: 40 }, (_, index) => ({
  id: `event-${index}`,
  image: index % 4 === 3 ? undefined : '/cart-demo-event.svg',
  name: `${names[index % names.length]} ${Math.floor(index / names.length) + 1}`,
  venue: venues[index % venues.length],
  status: statuses[index % statuses.length],
  sold: (index * 37) % 2400
}))

const field = recordFields()
const fields = [
  field.text('image', { label: 'Image', searchable: false, sortable: false }),
  field.text('name', { label: 'Event' }),
  field.text('venue', { label: 'Venue' }),
  field.option('status', {
    label: 'Status',
    status: {
      'on-sale': { intent: 'success', label: 'On sale' },
      'selling-fast': { intent: 'warning', label: 'Selling fast' },
      'sold-out': { intent: 'neutral', label: 'Sold out' }
    }
  }),
  field.number('sold', { label: 'Sold' })
]
const column = tableColumns(fields)
const columns = [
  column.field('image', { kind: 'image' }),
  column.field('name', { pin: true, narrow: 'title' }),
  column.field('venue', { narrow: 'description' }),
  column.field('status', { narrow: 'trailing' }),
  column.field('sold', { priority: 1 })
]

function NarrowEvents() {
  const [narrow, setNarrow] = useState(true)
  return (
    <div className='grid gap-3'>
      <ToggleGroup aria-label='Width' value={[narrow ? 'narrow' : 'wide']} onValueChange={([value]) => value && setNarrow(value === 'narrow')}>
        <ToggleGroup.Item value='narrow'>360px</ToggleGroup.Item>
        <ToggleGroup.Item value='wide'>Wide</ToggleGroup.Item>
      </ToggleGroup>
      <div className={narrow ? 'w-full max-w-90 px-3' : undefined}>
        <RecordTable
          caption='Events'
          data={events}
          fields={fields}
          columns={columns}
          getRowId={(row) => row.id}
          getRowHref={(row) => `#${row.id}`}
          recordName={{ one: 'event', other: 'events' }}
          maxHeight='28rem'
        />
      </div>
    </div>
  )
}

render(<NarrowEvents />)
```

#### Cards

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.

```tsx
const venues = ['Kazoo Hollow Room', 'Antler Kettle Hall', 'Echidna Gaslight Theatre', 'Foxglove Tin Pavilion']
const names = ['Marzipan Thunderclap', 'Ember Galah Ball', 'Nectarine Hoedown', 'Tuxedo Possum Cabaret', 'Lampshade Disco']
const events = Array.from({ length: 30 }, (_, index) => ({
  id: `event-${index}`,
  name: `${names[index % names.length]} ${Math.floor(index / names.length) + 1}`,
  venue: venues[index % venues.length],
  sold: (index * 37) % 2400,
  capacity: 2400,
  gross: (index * 7919) % 250000
}))

const field = recordFields()
const fields = [
  field.text('name', { label: 'Event' }),
  field.text('venue', { label: 'Venue' }),
  field.number('sold', { label: 'Sold' }),
  field.number('capacity', { label: 'Capacity' }),
  field.money('gross', { label: 'Gross', format: 'compactCurrency' })
]
const column = tableColumns(fields)
const columns = [
  column.field('name', { pin: true, narrow: 'title' }),
  column.field('venue', { narrow: 'description' }),
  column.field('sold', { narrow: 'detail' }),
  column.field('capacity', { narrow: 'detail' }),
  column.field('gross', { narrow: 'detail' })
]

render(
  <div className='w-full max-w-90 px-3'>
    <RecordTable
      caption='Events'
      data={events}
      fields={fields}
      columns={columns}
      getRowId={(row) => row.id}
      recordName={{ one: 'event', other: 'events' }}
      maxHeight='32rem'
    />
  </div>
)
```

#### Select mode

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.

```tsx
const customers = ['Mia Tran', 'Jack Ellis', 'Ava Nguyen', 'Noah Smith', 'Isla Brown', 'Leo Wilson']
const orders = Array.from({ length: 30 }, (_, index) => ({
  id: `order-${index}`,
  number: `OZ-${48210 + index * 7}`,
  customer: customers[index % customers.length],
  total: [79.9, 149, 59, 238.5][index % 4]
}))

const field = recordFields()
const fields = [
  field.text('number', { label: 'Order' }),
  field.text('customer', { label: 'Customer' }),
  field.money('total', { label: 'Total', format: 'currency' })
]
const column = tableColumns(fields)
const columns = [
  column.field('number', { pin: true, narrow: 'title' }),
  column.field('customer', { narrow: 'description' }),
  column.field('total', { narrow: 'trailing' })
]

render(
  <div className='w-full max-w-90 px-3'>
    <RecordTable
      caption='Orders'
      data={orders}
      fields={fields}
      columns={columns}
      getRowId={(row) => row.id}
      recordName={{ one: 'order', other: 'orders' }}
      bulkActions={[
        { label: 'Export', icon: <ExportIcon weight='bold' />, onAction: ({ selection }) => console.log('export', selection) },
        { label: 'Resend tickets', icon: <EnvelopeIcon weight='bold' />, onAction: ({ selection }) => console.log('resend', selection) }
      ]}
      maxHeight='28rem'
    />
  </div>
)
```

## Guidelines

### Keep the search in the table's header

**Do**

```tsx
<Pane.Body>
<Records.Header searchPlaceholder='Search events' />
<Records.Content />
</Pane.Body>
```

Put the search in the header above the rows, where it sticks while the
body scrolls and clearly searches this table.

**Don’t**

```tsx
<Pane.Header>
<Pane.Title>Events</Pane.Title>
<Pane.Search value={search} onValueChange={setSearch} />
</Pane.Header>
```

Don't move a table's search into `Pane.Search`. It reads as a search of
the whole pane.

### Describe fields once, at module scope

**Do**

```tsx
const field = recordFields<Event>()
export const eventFields = [field.text('name', { label: 'Event' })]
const column = tableColumns<Event>(eventFields)
const columns = [column.field('name', { pin: true })]
```

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.

**Don’t**

```tsx
function Events() {
const fields = [{ key: 'name', label: 'Event', type: 'text' }]
return <RecordTable fields={fields} columns={...} data={events} />
}
```

Don't rebuild fields or columns on every render. The table resizes its
columns and renders every row again each time.

### Pin one title column

**Do**

```tsx
column.field('name', { pin: true })
```

Pin the column that names the record. It stays in view as the table
scrolls sideways and reads in strong text.

**Don’t**

```tsx
column.field('name', { pin: true }),
column.field('venue', { pin: true }),
column.field('location', { pin: true })
```

Don't pin several columns. Each one takes width from the columns that
scroll.

### Set a page's scope, don't add it as a filter

**Do**

```tsx
<RecordTable scope={[{ field: 'venue', operator: 'is', values: [venueId] }]} {...props} />
```

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.

**Don’t**

```tsx
<RecordTable defaultView={{ query: { filters: [{ field: 'venue', operator: 'is', values: [venueId] }] } }} {...props} />
```

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.

### Let people copy a preset, not change it

**Do**

```tsx
viewActions={preset ? { onSaveAs } : { onSave, onSaveAs, onRename, onDelete }}
```

Give a preset Save as new view alone. People make it their own without
changing the screen's starting point for everyone.

**Don’t**

```tsx
viewActions={{ onSave: savePreset, onSaveAs }}
```

Don't let one person save over a preset. Everyone who opens the screen
then starts from their filters.

### Keep the whole view in the URL

**Do**

```tsx
router.replace(`?${recordViewToSearchParams(view, position)}`)
```

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.

**Don’t**

```tsx
router.replace(`?view=${view.id}`)
```

Don't write the id alone. A reload loses the changes without a word.

### Put the More menu last

**Do**

```tsx
actions={[
{ label: 'Export CSV', onAction: exportCsv },
{ label: 'Print door list', onAction: printDoorList }
]}
```

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.

**Don’t**

```tsx
<Records.Header>
<Menu>{/* More actions */}</Menu>
<Records.Search />
<Button>Export CSV</Button>
</Records.Header>
```

Don't put a menu before the search or other actions. The More menu
always comes last in an action group.

### Let people arrange columns in Configure

**Do**

```tsx
<Records.Header searchPlaceholder='Search events' />
```

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.

**Don’t**

```tsx
<Menu>
<Menu.Trigger render={<Button>Columns</Button>} />
<Menu.Content>{/* a checkbox per column */}</Menu.Content>
</Menu>
```

Don't build a separate columns menu. It can't reorder, and its state
never reaches the view or the URL.

### Act on the selection the bar shows

**Do**

```tsx
bulkActions={[{
label: 'Cancel',
intent: 'danger',
onAction: ({ selection, request }) => cancel(selection, request)
}]}
```

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.

**Don’t**

```tsx
onAction: () => cancel(mySelectedIds)
```

Don't keep your own list of ids. It can hold records the search has
since hidden, which people never confirmed.

### Let the table show its own states

**Do**

```tsx
<RecordTable loading={isLoading} error={failed} onRetry={refetch} {...props} />
```

Pass `loading`, `error`, and `onRetry`. The table shows the matching
EmptyState, placeholders, and announcements.

**Don’t**

```tsx
{failed ? <p>Something went wrong</p> : <RecordTable {...props} />}
```

Don't swap the table for your own message. The header and pager
disappear, and screen readers hear nothing.

### Load by range when people scroll and come back

**Do**

```tsx
<RecordTable loadRange={async (request) => ({ rows, rowCount })} {...props} />
```

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.

**Don’t**

```tsx
<RecordTable data={everyRecord} defaultPosition={{ pageSize: 100000 }} {...props} />
```

Don't load every record up front to avoid paging. Use pages when people
jump to a known page or the list is short.

## Accessibility

- `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.
- A sortable column's header is a button. The sorted column carries `aria-sort`.
- When the table scrolls sideways, its frame is a region named "Shows, scrolls sideways" for a caption of Shows, and the scroller is its one tab stop, so keyboard users can scroll it with the arrow keys. In its own box (`maxHeight` or `fill`), the box is the region, named "Shows, scrolls".
- Wide, the totals are a row in the table's head, under the column headers, with its label as the row's header, so each total reads with its column's name. Narrow, they're a paragraph above the list, each total after its heading.
- `Records.Status` is a live region. It announces Loading, the error, and the result count while a search or filter narrows the list.
- When the table holds only some of its rows (a page of several, more than 100 rows, or a list loaded by range), it carries `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.
- Loading by range, the table is `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".
- State titles are paragraphs, not headings, since the table can't know the page's outline. Their icon tiles are hidden from screen readers.
- The search is a combobox named "Search and filter", or by its `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](#keys) lists what each key does.
- Chips are a toolbar named "Filters", and Left and Right move between them. Selecting a chip lists its values and keeps focus in the field. Each chip's remove button is named "Remove" and the chip. A locked scope chip says it's set by this page, and a chip's dates, or a note that it can't apply, are read after its label.
- The operators are a toggle group named "Operator". A number's operators are symbols named in words, such as "is less than" for `<`.
- A date's calendar is named after the field, such as "Start date". Focus stays in the field as you press it, and Tab moves through the operators, so the keyboard picks a date by typing it.
- Each record's checkbox is named "Select" followed by the record's title, or its id with no title, and the header checkbox is named "Select page", with a mixed state when only some of the page is checked.
- `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.
- While records are selected, the bulk actions are a toolbar named "Bulk actions" in the header row, beside "Select page", which keeps focus as the bar replaces the column headers. The headers stay in the table for screen readers, and the toolbar sits in a last header with no cells under it. Arrow keys move between its controls. Escape clears the selection and returns focus to "Select page".
- Each row's actions menu is named "More actions for" the record's title. Table actions past the first sit behind a button named "More actions". A running action's control stays focusable but disabled, with `aria-busy`. A confirmation is an alert dialog.
- The view button is named "View:" and the view's name, with ", unsaved changes" once the view has changed, as its tooltip shows, and opens a menu. Save view and Reset view sit in a group named "Unsaved changes". Save as new view and Rename view open a dialog of that name with focus in its Name field, the whole name selected to rename, and an empty name says "Enter a name". Delete view asks in an alert dialog. Focus returns to the view button as each closes. While a handler runs, its control stays focusable but disabled, with `aria-busy`.
- The Configure button is named "Configure table", as is the dialog it opens. Given more than one layout, its Layout switcher is a group named "Layout" of toggle buttons, with the shown layout pressed. Its sort controls are named "Sort by", then "Then by, level 2" and so on, and for each level the field's order, such as "Sold order". Adding a level moves focus to its field, and removing one moves focus to the Remove of the level that takes its place, or of the one before it, or to Add sort. Each column's handle is named "Reorder" and the column, its eye is a toggle button named "Show" and the column, pressed while the column shows (the last shown column's is disabled and says why), and each move is announced, such as "Gross moved to position 1 of 4 columns".
- In Select mode, each record's checkbox covers the record, so a tap anywhere on it toggles, and the link steps aside. "Select" and "Done" are one button, which keeps focus as it switches. Select mode's controls are a group named "Select mode", and the floating bulk actions a group named "Bulk actions", where Escape leaves Select mode. When the bar goes, focus lands on Select, or on the records.
- Focus follows the record across a switch between the table and the list, onto the same record's link or first control.
- A row with `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.

## Hooks

### useRecords

```tsx
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.                                                                                                                                                                                           |

## API reference

### RecordTable

Records in a table with the standard toolbar, pagination and status.

```tsx
import { RecordTable } from '@oztix/roadie-components/record-table'
```

- `data`: `readonly Row[]`. 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.
- `rowCount`: `number`. 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.
- `rowCountExact`: `boolean`. Defaults to `true`. 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.
- `fields`: `readonly RecordsField[]`. Required. What each record holds. Keep the array stable (module scope or `useMemo`).
- `getRowId`: `((row: Row, index: number) => string)`. 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.
- `view`: `RecordView`.
- `defaultView`: `RecordViewDefaults`.
- `onViewChange`: `((view: RecordView, position: Required<RecordPosition>) => void)`. Gets the position too: a change to the search, filters or sort returns to the first page, and `onPositionChange` hears that first.
- `baseline`: `RecordView`. 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.
- `position`: `RecordPosition`. Page, page size and, in range mode, the first row on screen: session state, never part of a view.
- `defaultPosition`: `RecordPosition`.
- `onPositionChange`: `((position: Required<RecordPosition>) => void)`.
- `recordName`: `RecordName`. Defaults to `{ one: 'record', other: 'records' }`.
- `totals`: `RecordsTotals`. 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.
- `loading`: `boolean`. A fetch is under way. With no records the layout shows placeholders; with records it dims them.
- `error`: `string | boolean`. The fetch failed. A string is shown as the message.
- `onRetry`: `(() => void)`. Shows a Retry button beside the error.
- `timeZone`: `string`. The viewer's IANA zone: it decides what "today" is and how timestamps read. Defaults to the browser's.
- `now`: `Instantish`. 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.
- `scope`: `readonly RecordFilter[]`. 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`.
- `selectable`: `boolean`. Defaults to `false`. Lets people pick records for bulk actions.
- `selection`: `RecordSelection`. Session state, like the position. A search or filter change drops picked records it hides.
- `defaultSelection`: `RecordSelection`.
- `onSelectionChange`: `((selection: RecordSelection) => void)`.
- `rowActions`: `((row: Row) => ReactNode)`. Menu items for one record, such as `Menu.Item`s, shown in a menu on its row.
- `getRowHref`: `((row: Row) => string)`. The record's page. Return `undefined` for a record with none.
- `loadRange`: `((request: RecordsRangeRequest) => Promise<RecordsRangeResult<Row>>)`. 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.
- `onRequestChange`: `((request: RecordsRequest) => void)`. 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.
- `columns`: `readonly RecordTableColumn<Row>[]`. Required.
- `caption`: `string`. Accessible name for the table.
- `searchPlaceholder`: `string`. Defaults to `'Search and filter'`. Names the search field too, unless `searchLabel` is given.
- `searchLabel`: `string`. Names the search field when its placeholder doesn't.
- `searchFocusShortcut`: `string | false`. Defaults to `'/'`. The key that focuses the search, a `KeyboardEvent.key` value such as `/`, or `false` for none.
- `pageSizes`: `number[]`. Defaults to `[25, 50, 100]`.
- `bulkActions`: `readonly RecordsBulkAction[]`. Turns on selection and shows the bulk actions once something is selected.
- `actions`: `readonly RecordsAction<Row>[]`. Act on everything the search and filters match, with nothing selected, such as an export. Shown at the end of the toolbar.
- `viewActions`: `RecordsViewActionsProps`. 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.
- `narrow`: `"list" | "cards"`. How the table shows its records under 40rem. Defaults to cards when a column is a `detail`, else list rows.
- `maxHeight`: `string`. Scrolls the rows in their own box this tall, any CSS length.
- `fill`: `boolean`. Fills its parent's height and scrolls both ways inside, like `maxHeight`. The parent needs a definite height, such as `Pane.Body`.
- `className`: `string`.

### RecordValue

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.

```tsx
import { RecordValue } from '@oztix/roadie-components/records'
```

- `field`: `RecordField`. Required.
- `row`: `object`. Required.
- `timeZone`: `string`. The viewer's zone. Defaults to the browser's.
- `className`: `string`.

Docs updated 24 Oct 2026.
