` (the parser drops the tags). Same contract as the two state snippets below. |
| enableColumnReorder | `boolean` | no | false | Enable drag-and-drop column reordering on desktop. Users can drag column headers to rearrange them. Also supports keyboard reorder via Shift+ArrowLeft/Right on focused headers. |
| enableColumnVisibility | `boolean` | no | true | Enable the column-visibility feature: the visibility menu in the smart filter bar and the "hide column" action in every header menu. Set `false` to remove both — this also reveals every currently-hidden column (including one restored from persistence), so no column is ever stranded hidden without a way back. For per-column control, set `hideable: false` on individual columns instead. |
| enableLiveUpdates | `boolean` | no | false | Enable live update support. When enabled, a `LiveUpdateBanner` is shown when pending inserts/updates/deletes are buffered. Get hold of `pushInsert`, `pushUpdate`, `pushDelete` for your WebSocket/SSE handler via `onReady` — or, from inside the table's own tree (a `toolbar` snippet, a custom cell), via `getTableContext()`. |
| enableSmartFilter | `boolean` | no | true | Enable smart filtering functionality |
| errorState | `Snippet` | no | undefined | Custom error state snippet. Named after its `slotClasses.errorState` slot (renamed from `error` in v6.41, alongside its two siblings). Table-row markup, desktop only — mobile renders `errorText`. |
| errorText | `string` | no | i18n `error.loadingError` | Text displayed on error |
| expandedRowContent | `Snippet<[item: T]>` | no | undefined | Snippet to render expanded row content |
| fit | `'content' | 'viewport'` | no | "content" | Make the table its own scroll container so wide **and** long lists scroll *within* the table instead of pushing overflow onto the page. - `'content'` (default): the table grows with its content; vertical overflow scrolls the page. Pair with `sticky` for page-relative pinning. - `'viewport'`: the table is height-capped to the viewport and becomes a self-contained scroll box. The column header (and group header when grouping) pin to the top of the box and a total summary row to its bottom, while the toolbar and pagination stay fixed outside the scrolling area — only the rows scroll, in both axes. The available height is measured automatically: the viewport, minus the space reserved above the container by a pinned or clipping ancestor (zero in the plain page flow, where the box can reach the top of the viewport). No magic `max-height` is needed in the consumer. Notes for `'viewport'`: - An assertion, not a measurement: it tells the table it owns the page height, and it is honoured at every width. Set it only on a table that is the page's primary content — inside a scrolling article the table caps itself against the viewport all the same and gives you a second scroller. - Supersedes `sticky`: header/group pinning is intrinsic to the box, so the `sticky` prop is ignored. `stickyOffset` still counts, as a floor under the measured reservation: an app-shell bar that is an *ancestor* of the table is measured, a `position: fixed` bar that is only a sibling is not — declare its height and the cap leaves room for it. - Mutually exclusive with `virtualized`, which already has a bounded scroll box of its own (`virtualHeight`) and pins the same two layers against it; `fit` has no effect when `virtualized`, and a DEV-build console warning names the combination — the refusal is otherwise invisible, since `data-fit` publishes the resolved `"content"`. - The box reaches the bottom of the viewport, so it assumes nothing sits *below* it. An ancestor with bottom padding (or a following sibling) is pushed past `100dvh` and produces a second, page-level scrollbar next to the table's own. The container reflects the resolved mode as `data-fit="viewport"` (vs `"content"`, also when `virtualized`), so a layout can drop that inset — at every width, exactly as the cap applies at every width: `main:has([data-fit='viewport']) { padding-block-end: 0 }`. |
| groupHeaderContent | `Snippet<[groupName: string, items: T[], isExpanded: boolean]>` | no | null | Custom content for group headers |
| groupOrder | `string[]` | no | [] | Custom order for group display |
| header | `Snippet` | no | undefined | Custom header snippet — the `
` of the table, in place of the built-in one. In the virtualized layout the table sits in a scroll box of `virtualHeight` and the built-in head pins itself to the top edge of that box; a custom `` scrolls away with the rows unless it pins itself. The classes the built-in head uses there are `tableHeaderVariants({ sticky: 'box' }).header()` and `.row()` (exported from the package) — `sticky top-0 z-20 bg-surface-elevated` on the ``, plus an opaque ground and the underline on its ``. |
| initialSelectedIds | `Array` | no | undefined | Initial selected row ids (if no persisted value exists). Seeds the uncontrolled selection once when the table is created. Ignored entirely when the controlled `selectedIds` prop is set — controlled always wins. A selection restored via `prefs.persistSelection` takes precedence. Later changes to this prop are ignored; users can still change or clear the selection. Rows are keyed by `item.id` (row-index fallback). Precedence when combined with `prefs.persistSelection` goes by *presence*, not emptiness: once storage holds a selection for this table — including the empty one written when the user deselected everything — it wins and the seed no longer applies. |
| items | `T[]` | no | [] | Array of data items to display in the table — the shorthand for `source={{ processing: 'client', items }}`, and the right prop whenever the rows are all you have to say. Reach for `source` once loading, error or a server total come into it. Items with an `id` property get better key stability for animations. If no `id` is present, the array index is used as fallback key. |
| loadingState | `Snippet` | no | undefined | Custom loading state snippet, rendered while the `source` reports loading. Named after its `slotClasses.loadingState` slot. Table-row markup, desktop only — mobile renders `loadingText`. |
| loadingText | `string` | no | i18n `data.loading` | Text displayed during loading state. The loading *state* itself comes from the `source` — `{ items, loading }` for data you fetch yourself, or the managed `{ query }` flow where the table drives it. |
| mobileCardDetails | `'collapsed' | 'expanded'` | no | "collapsed" | How much of a record a mobile card shows before it is opened. Below `TableProps.cardsBelow` of the table's **own container** — not of the window — the table renders one card per row instead of the grid. - `collapsed` (default): the card shows the first two card columns — title and label-less subtitle — and opens the rest on tap. A record costs roughly a third of the height, so a phone screen holds three instead of one. - `expanded`: title on top, every other card column in the grid below it, nothing hidden. The shape before v6.48. Independent of `expandedRowContent`, which stays behind the chevron in both modes. |
| multiExpand | `boolean` | no | false | Allow multiple rows to be expanded simultaneously. When false (default), expanding a row collapses the previously expanded one. |
| noDataText | `string` | no | i18n `data.empty` | Text displayed when no data is available |
| onReady | `(context: TableContext) => void` | no | | Called once with the table's context after the table is set up — the supported way to reach the imperative API from *outside* the table's tree (`getTableContext()` only resolves inside it). The context is a live object typed as `TableContext` — since v8 a hand-written, deliberately narrow surface: `pushInsert`/`pushUpdate`/ `pushDelete` and `applyAllUpdates` for live feeds, the reactive `state`, the derived collections and the documented action methods. Hold on to it for the lifetime of the table; it is not re-created. `state.items` and `state.columns` are reactive to *replacement*, not to writes reaching inside them: `state.items[0].name = 'x'` changes the row and re-renders nothing. Edit a row through `pushUpdate`, or assign a new array. |
| onRowClick | `(item: T) => void` | no | undefined | Callback fired when a row is clicked. Receives the clicked row's data item. |
| onSelectionChange | `(selectedItems: T[], selectedIds: Array) => void` | no | undefined | Callback fired when the selection changes — and only then. Paging, sorting or a new page of server rows do not fire it. The first argument is the selected rows, the second their ids. The two are **not** interchangeable: rows can only be handed over for the items the table currently holds, so under `processing: 'server'` the first argument carries the selected rows *of the loaded page* while the second carries the whole selection. The header checkbox states the same scope: in server mode it selects the loaded page and says so (mixed state, page-scoped label) — a selection here never silently claims rows the table has not seen. With controlled `TableProps.selectedIds`, write the **ids** back — `(items, ids) => (selectedIds = ids)`. Mapping the rows instead (`items.map((item) => item.id)`) is correct in client mode and silently drops every row from another page in server mode. |
| pagination | `Snippet` | no | undefined | Custom pagination snippet |
| prefs | `TablePrefsConfig` | no | undefined | Preference channel (#152): column visibility, column order, summaries — and, opt-in, the selection. Preferences belong to the table, not the view: nobody wants to share a link that hides columns on the other end, so they live in web storage, never in the URL. `storage` names the storage key (string shorthand or `{ key, kind?, debounceMs? }`); `defaults` are the initial preferences for a table nobody touched (applied at construction, SSR-visible); `persistSelection: true` opts the selection into storage. Using the same key string here and in `bindViewToStorage` is a naming convention, not a link: the two channels stay independent, so persisting both the view and the preferences always takes both statements. |
| preset | `string` | no | undefined | Apply a named preset registered via ``. A preset's `slotClasses` use the same slot names as `TableProps.slotClasses` and sit between the provider's `defaults.Table` and this instance's own `slotClasses`. The resolved classes reach every subcomponent through the table style context, so a preset styles the frame, the header, the rows, the cells and the card list alike. Prop-conditional `overrides` (in `defaults.Table` or in a preset) match `variant`, `size`, `cardsBelow`, `stickyToolbar` and `contained`. Those five, and no others: `tableContainerVariants` also carries `pinnedSummary`, which the table decides per render rather than per instance and does not publish here. The last two of the five are the resolved modes, not the props they derive from: `fit="viewport"` matches `{ contained: true }`, and a toolbar pinned to the page (`sticky`, `sticky="toolbar"` or `"both"`, unless the table resolves to `contained`) matches `{ stickyToolbar: true }`. A name no provider registers applies nothing and warns in development. |
| rowClickSelects | `boolean` | no | false | Whether clicking anywhere on a row body toggles that row's selection, in addition to the always-present checkbox. On by default in `selectionMode="single"` (where a single click is the expected gesture and there is no marquee/range interaction to conflict with), as long as the row click means nothing else yet — neither `onRowClick` nor `expandedRowContent` is set. Set it explicitly to opt in for `multi` or for expandable rows, or to `false` to keep the checkbox as the only selection target. A click that ends a text selection *inside the row* never selects, so cell content stays copyable. Applies to desktop rows (flat and grouped); mobile cards keep the checkbox as their only selection control, since a selectable card cannot be a button without nesting interactive elements. Defaults to `true` only in `selectionMode="single"` without `onRowClick` and without `expandedRowContent` — a row click that already expands must not silently also select. |
| searchDebounceMs | `number` | no | undefined — the table's own mode-aware wait, which is not 0 | Debounce delay for search in milliseconds — the whole wait between a keystroke and the search taking effect, in either processing mode. Left unset, the wait depends on the mode: 300 ms while the table filters client-side; in server mode the field writes through immediately and whoever fetches does the waiting — the managed source's own `debounceMs` (300 ms by default), or, where you fetch yourself, whatever delay you put in front of your fetch. An explicit value is honoured in both modes and stays the *total* against a managed source (`source={{ processing: 'server', query }}`): the search field holds the write back for it, and the fetch that write triggers goes out at the end of that wait instead of adding `source.debounceMs` on top. `searchDebounceMs={300}` against a source debouncing 300 ms fetches at 300 ms, not at 600; `searchDebounceMs={0}` fetches at once. It covers the whole field, typing and clearing alike — Escape takes the same route as a backspace, so the two clear at the same moment. Search is the only change exempted: sort, filter, page and page size keep the source's debounce in full. Two things this does not reach. **Coalescing moves with the delay:** at an explicit value the bar's timer is what collapses a burst of keystrokes, and the source's debounce no longer folds search requests together — with `searchDebounceMs={150}` against `debounceMs: 800`, every typing pause over 150 ms sends a request (each superseded one is aborted). Leave the prop unset to have the source's debounce do that job. **And the promise is the managed flow's:** where you fetch yourself (`{ processing: 'server', items, total }` plus `observeView`, or any hand-rolled effect), the table does not know about your delay and cannot subtract itself from it — an explicit value and your own debounce add up there, exactly as they always did. |
| searchPlaceholder | `string` | no | i18n `search.placeholder` | Placeholder text for search input |
| selectedIds | `Array` | no | undefined | Controlled selected row ids. When set, `initialSelectedIds` is ignored and the table adopts this value: it seeds the selection at construction — the server HTML carries the selected rows — and every later prop value replaces the selection. An empty array is a valid value — nothing selected; `undefined` returns ownership to the table. User clicks still change the selection and fire `onSelectionChange` — write the new ids back into this prop, or the next change to it discards what the user clicked. Never written to storage (`prefs.persistSelection` has no effect), and a stored selection never overrides it — the prop is the source of truth. |
| selectionMode | `'none' | 'single' | 'multi'` | no | "none" | Row selection mode. - `'none'`: No selection (default) - `'single'`: Only one row can be selected at a time - `'multi'`: Multiple rows can be selected with checkboxes |
| size | `'sm' | 'md' | 'lg'` | no | "md" | Size variant for the table |
| slotClasses | `Partial` | no | {} | Per-slot class overrides merged with (or replacing, if `unstyled`) variant styles. Available slots: `container`, `toolbar`, `scrollArea`, `table`, `thead`, `tbody`, `headerRow`, `headerCell`, `row`, `cell`, `groupHeader`, `summaryRow`, `emptyState`, `loadingState`, `errorState`, `filterBar`, `mobileCard`. The strongest layer of the `` cascade: the provider's `defaults.Table.slotClasses`, then its matching `overrides`, then the active `preset`, then this prop. A later layer wins per Tailwind bucket, so a preset's `cell: 'p-2'` gives way to an instance `cell: 'p-4'`. **Breaking change in v1.5:** the former `wrapper` slot has been replaced by `scrollArea`. The former hardcoded `overflow-hidden` on `wrapper` blocked `position: sticky`, see [docs/STICKY-PINNING.md](../../../../../docs/STICKY-PINNING.md). In the same Tailwind bucket `class` wins over the `slotClasses` entry for the element it lands on. Presets and provider defaults sit below both. |
| source | `TableSource` | no | undefined | Where the rows come from, and **who processes them** — sorts, filters, searches and pages. Three shapes, and the invalid combinations of the old `mode`/`queryFn`/`loading`/`error`/`serverTotal` props are not expressible: - `{ processing: 'client', items, loading?, error? }` — the table does that work in the browser; you fetched the rows, so `loading`/`error` are yours to report - `{ processing: 'server', items, total, loading?, error? }` — your backend does it; you fetch and hand in each page - `{ processing: 'server', query, debounceMs? }` — same, and the table calls `query` when the view changes (first fetch immediate, later ones debounced), manages loading/error and aborts superseded requests `processing` is required on every variant: it decides whether the reader's sort headers reorder the page in front of them or ask your backend for a different one, which is too visible a difference to be inferred from a `total` that happened to be passed. For rows and nothing else, reach for `items` — `source={{ processing: 'client', items }}` says the same thing. `source` wins when both are set. |
| sticky | `boolean | 'toolbar' | 'header' | 'both'` | no | false | Pin toolbar/header/group-header to the top of the scroll ancestor on scroll. - `false` (default): no pinning, layout matches v1.4.x - `true` / `'both'`: toolbar + thead + group-header all pin - `'toolbar'`: only the toolbar pins - `'header'`: only the thead (and group-header when grouping is active) pins Pair with `stickyOffset` for app shells that have a fixed top bar. For tables wider than the viewport, prefer `fit="viewport"` — it contains horizontal scroll inside the table instead of falling back to page-level horizontal scroll (a sticky pin host cannot also be a horizontal scroll ancestor). `fit="viewport"` supersedes `sticky` when set. |
| stickyOffset | `number` | no | 0 | Pixel offset for the topmost sticky layer (toolbar, or thead when no toolbar). Writes the CSS custom property `--blocks-table-sticky-top` on the container. Use this to push the pin below a fixed app shell top bar. With `fit="viewport"` the same figure is a floor under the space the box reserves above itself, for chrome the measurement cannot see (a fixed sibling bar). |
| toolbar | `Snippet` | no | undefined (renders default SmartFilterBar when enableSmartFilter) | Custom toolbar snippet. Replaces the default `SmartFilterBar`. Renders inside the sticky toolbar wrapper when `sticky` is enabled — so a custom toolbar inherits the pinning behavior without extra wiring. Access the table context via `getTableContext()` to wire up custom filter UIs. |
| unstyled | `boolean` | no | false | Remove the default variant classes. Only user-provided `slotClasses` apply. `` sets the same switch, so the table and the search field inside its toolbar lose their look in the same step. What stays either way is the desktop/card layout switch: structure, not look. |
| variant | `'flush' | 'surface' | 'framed'` | no | "flush" | Visual style of the table chrome (see the shipped VARIANT-CONTRACT.md § Table chrome): - `flush` (default): no outer frame, sits inline in the reading flow - `surface`: gentle `surface-quiet` tinted zone, no border - `framed`: bordered + rounded + shadowed standalone block Applies to both layouts — on a narrow container the same frame wraps the mobile record list, whose records are separated by hairlines instead of each carrying a frame. |
| view | `TableView` | no | undefined | The view object — the six shareable axes (search, sort, page, pageSize, filters, groupBy) as one consumer-constructed reactive object, fully resolved against its `defaults`. The table reads and writes its fields directly (`view.page`, `view.sort`, …); decorate it with `bindViewToUrl` (from `@urbicon-ui/sveltekit-utils`) and/or `bindViewToStorage` to give the axes a home — the bindings are decorations over the object, not props of the table. Leave unset for a table that owns its view (zero-config); use `viewDefaults` to adjust the defaults of that owned view. Passing both fails loud (also in prod — a miswired view corrupts state either way). Resolved once, at construction: a view is an identity, not a value — a later change of this prop is ignored. The table's own interaction handlers reset the page on a new search, filter or grouping; a *direct field write* (`view.search = 'x'`) does not — write `view.page = 1` alongside, or go through the context's `setSearch`. **Sharing one view across tables.** Several tables may mount the same view: they read and write the same six axes, and a table takes no claim of its own (a remounting `{#if}` child inherits the current state). A **virtualized** table renders any grouping the view carries as ungrouped — for itself only: the value stays on the view, and an un-virtualized sibling of the same view keeps rendering it grouped. One limit is worth knowing: a **managed source** (`{ query }`) on both tables fetches once *per table* per interaction — a shared view is not a shared cache; wire the fetch once yourself and hand both tables a manual `processing: 'server'` source if that matters. |
| viewDefaults | `TableViewDefaults` | no | undefined | Defaults for the view the table owns when no `view` prop is passed — the one-liner for the most common configuration: `viewDefaults={{ pageSize: 25 }}`. Mutually exclusive with `view` (a consumer-owned view carries its own defaults); passing both fails loud. Resolved once, at construction — a later change of this prop is ignored. |
| virtualHeight | `string` | no | "600px" | Height of the virtualized table's scroll box — the whole box, the column header and the summary row included: both are pinned inside it, so the rows get `virtualHeight` minus the two. Only used when `virtualized` is true. Accepts any CSS height value. The summary pin is not this box's own trick: a total summary pins to the bottom edge of whichever scroll box the table owns, `virtualized` or `fit="viewport"`. While the list is too short to fill this box — a filter that matches a handful of rows — the summary sits under the last row instead: `position: sticky` cannot leave the ``, and a short table does not reach the bottom of the box. |
| virtualized | `boolean` | no | false | Enable virtualization for large datasets. When enabled, only visible rows are rendered for performance with >1000 items. In client mode pagination is bypassed — the scrollable container holds all filtered/sorted rows. In server mode the pager stays: the container scrolls the loaded page, and paging remains the way to the rest of the result. Not compatible with grouping — and virtualization wins: the grouping affordances are suppressed, and a grouping arriving through the view (its defaults, a URL, storage) renders ungrouped (DEV warns). The value itself stays on the view and in the URL — an un-virtualized table reading the same view still groups, and a stored grouping applies again on the next load without `virtualized`. |
### Types
- `interface TableContext` — 48 members, definition omitted (224 lines; full definition: packages/table/src/lib/core/table/index.ts)
```ts
type Column = | DataColumnString
| DataColumnFunction
| SyntheticColumn
```
```ts
type TableItem = Record
```
```ts
interface TablePrefsConfig {
/**
* Where to persist the preferences. A string is the shorthand for
* `{ key }`; without this field the preferences stay volatile and only
* `defaults` apply.
*/
storage?:
| string
| {
/** Unique identifier for this table — used as the storage-key suffix. */
key: string;
/** Storage type. @default 'localStorage' */
kind?: 'localStorage' | 'sessionStorage';
/** Write debounce in ms. @default 500 */
debounceMs?: number;
};
/** Initial preferences for a table nobody has touched yet. */
defaults?: {
/** Column ids hidden initially. */
hiddenColumns?: string[];
/** Initial column order (resolved column ids). */
columnOrder?: string[];
/** Initial summary configurations. */
summaries?: SummaryConfig[];
};
/**
* Persist selected row ids across reloads. **Opt-in — default `false`**,
* unlike every axis above (a restored selection surprises more often than
* it helps). No effect when `selectedIds` is controlled: the prop is the
* source of truth, so a controlled value is never written to storage.
* Rows are keyed by `item.id`; without a stable `id` the selection falls
* back to the row's position index, which restores onto *different* rows
* after a reorder — enable this only for data with stable ids.
*/
persistSelection?: boolean;
}
```
- `interface PageDescriptor` — 12 members, definition omitted (46 lines; full definition: packages/table/src/lib/stores/concerns/page-descriptor.ts)
- `interface TableState` — 25 members, definition omitted (152 lines; full definition: packages/table/src/lib/stores/concerns/types.ts)
```ts
interface LiveUpdateCounts {
inserts: number;
updates: number;
deletes: number;
total: number;
}
```
```ts
interface SummaryConfig {
/** Column key to aggregate */
column: string;
/**
* Aggregation type — derived from the one vocabulary in
* `utils/summary-types.ts`, so the union and the rendered list of
* types cannot drift apart (#251).
*/
type: SummaryType;
/** Optional custom formatter for the aggregated value */
formatter?: (value: number) => string;
}
```
```ts
interface Filter {
/** Column ID the filter applies to (must match a column's resolved id) */
column: string;
/** Filter operator */
operator: FilterOperator;
/**
* Filter value (string — `greaterThan`/`lessThan` compare it as a number
* first, then as a date; see the interface docs)
*/
value: string;
}
```
```ts
type FilterOperator = | 'contains'
| 'equals'
| 'startsWith'
| 'endsWith'
| 'greaterThan'
| 'lessThan'
```
```ts
type CardsBelowStep = '24rem' | '28rem' | '32rem' | '36rem' | '42rem' | '48rem' | '56rem'
```
```ts
type TableSource = | ClientItemsSource
| ServerManualSource
| ServerManagedSource
```
```ts
class TableView {
readonly defaults: TableViewSnapshot;
constructor(defaults: TableViewDefaults = {});
get search(): string;
set search(value: string);
get sort(): ViewSort | null;
set sort(value: ViewSort | null);
get page(): number;
set page(value: number);
get pageSize(): number;
set pageSize(value: number);
get filters(): Filter[];
set filters(value: Filter[]);
get groupBy(): string | null;
set groupBy(value: string | null);
applyExternal(partial: Partial, origin: 'external'): void;
originOf(axis: ViewAxis): { revision: number; origin: ViewOrigin | 'init' };
snapshot(): TableViewSnapshot;
claimAxes(kind: BindingKind, axes: readonly ViewAxis[]): void;
releaseAxes(kind: BindingKind, axes: readonly ViewAxis[]): void;
markInitApplied(axes: readonly ViewAxis[]): void;
wasInitApplied(axis: ViewAxis): boolean;
markStorageApplied(axes: readonly ViewAxis[]): void;
wasStorageApplied(axis: ViewAxis): boolean;
}
```
```ts
type TableViewDefaults = Partial
```
```ts
interface ViewSort {
/** Column ID to sort by (must match a column's resolved id). */
column: string;
/** Sort direction. */
direction: 'asc' | 'desc';
}
```
- `interface TableSlotClasses` — 17 members, definition omitted (54 lines; full definition: packages/table/src/lib/core/table-style-context.ts)
- `interface DataColumnString` — 20 members, definition omitted (152 lines; full definition: packages/table/src/lib/types/tableTypes.ts)
- `interface DataColumnFunction` — 20 members, definition omitted (152 lines; full definition: packages/table/src/lib/types/tableTypes.ts)
- `interface SyntheticColumn` — 14 members, definition omitted (94 lines; full definition: packages/table/src/lib/types/tableTypes.ts)
```ts
type ProcessingMode = 'client' | 'server-manual' | 'server-managed'
```
```ts
type SummaryType = (typeof SUMMARY_TYPES)[number]['value']
```
```ts
interface ClientItemsSource {
processing: 'client';
items: T[];
loading?: boolean;
error?: string | null;
total?: never;
query?: never;
}
```
```ts
interface ServerManualSource {
processing: 'server';
items: T[];
/** Total number of items matching the query — drives pagination. */
total: number;
loading?: boolean;
error?: string | null;
query?: never;
}
```
```ts
interface ServerManagedSource {
processing: 'server';
/**
* Called with the view itself — the same six axes the reader manipulates,
* under the same names. Project it onto your backend's parameters inside
* the function.
*/
query: (q: TableViewSnapshot, options: { signal: AbortSignal }) => Promise;
/** Debounce for refetches after the immediate first fetch. @default 300 */
debounceMs?: number;
items?: never;
loading?: never;
error?: never;
total?: never;
}
```
```ts
interface TableViewSnapshot {
/** Full-text search term (`''` = no search). */
search: string;
/** Active sort, or `null` for unsorted — "unsorted" is a value, not a sentinel. */
sort: ViewSort | null;
/** Current page (1-based). */
page: number;
/** Number of items per page. */
pageSize: number;
/** Active column filters. */
filters: Filter[];
/** Column ID for grouping, or `null` for ungrouped. */
groupBy: string | null;
}
```
```ts
type ViewAxis = (typeof VIEW_AXES)[number]
```
```ts
type ViewOrigin = 'user' | 'external'
```
```ts
type BindingKind = 'url' | 'storage'
```
```ts
type DataAccessor = [PrimitiveKeys] extends [never] ? string : PrimitiveKeys
```
```ts
interface TablePage {
/** Items for the current page/query */
items: TableItem[];
/** Total number of items matching the query — drives pagination. */
total: number;
}
```