@tailor-platform/app-shell 1.15.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app-shell-core.css +1 -1
- package/dist/app-shell.d.ts +811 -131
- package/dist/app-shell.js +7155 -5797
- package/dist/{appshell-DLVBFo5J.js → appshell-CahhZy7v.js} +656 -524
- package/dist/semantic-contrast.test.ts +154 -0
- package/dist/style-entry.d.ts +147 -92
- package/dist/testing.d.ts +152 -95
- package/dist/testing.js +1 -1
- package/dist/theme-bridge.test.ts +101 -1
- package/dist/theme.bridge.css +91 -0
- package/dist/theme.css +6 -1
- package/dist/themes/_template.css +7 -1
- package/dist/themes/bloom.css +0 -6
- package/dist/themes/cream.css +0 -6
- package/dist/themes/default.css +389 -69
- package/dist/vite-plugin.d.ts +147 -92
- package/package.json +8 -8
- package/skills/app-shell-patterns/SKILL.md +108 -19
- package/skills/app-shell-patterns/references/api/create-ai-gateway-client.md +115 -0
- package/skills/app-shell-patterns/references/api/create-typed-paths.md +117 -0
- package/skills/app-shell-patterns/references/api/define-i18n-labels.md +143 -0
- package/skills/app-shell-patterns/references/api/define-module.md +247 -0
- package/skills/app-shell-patterns/references/api/define-resource.md +307 -0
- package/skills/app-shell-patterns/references/api/guards/hidden.md +202 -0
- package/skills/app-shell-patterns/references/api/guards/overview.md +354 -0
- package/skills/app-shell-patterns/references/api/guards/pass.md +129 -0
- package/skills/app-shell-patterns/references/api/guards/redirect-to.md +307 -0
- package/skills/app-shell-patterns/references/api/router/use-location.md +240 -0
- package/skills/app-shell-patterns/references/api/router/use-navigate.md +138 -0
- package/skills/app-shell-patterns/references/api/router/use-params.md +218 -0
- package/skills/app-shell-patterns/references/api/use-ai-chat.md +110 -0
- package/skills/app-shell-patterns/references/api/use-app-shell-config.md +132 -0
- package/skills/app-shell-patterns/references/api/use-app-shell-data.md +150 -0
- package/skills/app-shell-patterns/references/api/use-app-shell.md +150 -0
- package/skills/app-shell-patterns/references/api/use-auth.md +145 -0
- package/skills/app-shell-patterns/references/api/use-override-breadcrumb.md +74 -0
- package/skills/app-shell-patterns/references/api/use-page-meta.md +96 -0
- package/skills/app-shell-patterns/references/api/use-register-command-palette-actions.md +90 -0
- package/skills/app-shell-patterns/references/api/use-resolved-locale.md +67 -0
- package/skills/app-shell-patterns/references/api/use-route-error.md +214 -0
- package/skills/app-shell-patterns/references/api/use-theme.md +108 -0
- package/skills/app-shell-patterns/references/api/use-time-zone.md +80 -0
- package/skills/app-shell-patterns/references/api/use-toast.md +104 -0
- package/skills/app-shell-patterns/references/components/action-panel.md +168 -0
- package/skills/app-shell-patterns/references/components/activity-card.md +142 -0
- package/skills/app-shell-patterns/references/components/ai-chat.md +315 -0
- package/skills/app-shell-patterns/references/components/alert.md +202 -0
- package/skills/app-shell-patterns/references/components/app-shell.md +530 -0
- package/skills/app-shell-patterns/references/components/appearance-switcher.md +87 -0
- package/skills/app-shell-patterns/references/components/attachment.md +138 -0
- package/skills/app-shell-patterns/references/components/autocomplete.md +278 -0
- package/skills/app-shell-patterns/references/components/avatar.md +80 -0
- package/skills/app-shell-patterns/references/components/badge.md +309 -0
- package/skills/app-shell-patterns/references/components/button.md +164 -0
- package/skills/app-shell-patterns/references/components/card.md +181 -0
- package/skills/app-shell-patterns/references/components/checkbox.md +169 -0
- package/skills/app-shell-patterns/references/components/combobox.md +318 -0
- package/skills/app-shell-patterns/references/components/command-palette.md +413 -0
- package/skills/app-shell-patterns/references/components/csv-importer.md +214 -0
- package/skills/app-shell-patterns/references/components/data-table.md +1168 -0
- package/skills/app-shell-patterns/references/components/date-picker.md +259 -0
- package/skills/app-shell-patterns/references/components/default-header.md +75 -0
- package/skills/app-shell-patterns/references/components/default-sidebar.md +136 -0
- package/skills/app-shell-patterns/references/components/description-card.md +569 -0
- package/skills/app-shell-patterns/references/components/dialog.md +164 -0
- package/skills/app-shell-patterns/references/components/document-progress-card.md +154 -0
- package/skills/app-shell-patterns/references/components/filter-rail.md +165 -0
- package/skills/app-shell-patterns/references/components/form.md +378 -0
- package/skills/app-shell-patterns/references/components/global-header-layout.md +132 -0
- package/skills/app-shell-patterns/references/components/grid.md +184 -0
- package/skills/app-shell-patterns/references/components/input.md +83 -0
- package/skills/app-shell-patterns/references/components/layout.md +539 -0
- package/skills/app-shell-patterns/references/components/menu.md +218 -0
- package/skills/app-shell-patterns/references/components/metric-card.md +94 -0
- package/skills/app-shell-patterns/references/components/select.md +283 -0
- package/skills/app-shell-patterns/references/components/sheet.md +237 -0
- package/skills/app-shell-patterns/references/components/sidebar-group.md +472 -0
- package/skills/app-shell-patterns/references/components/sidebar-item.md +458 -0
- package/skills/app-shell-patterns/references/components/sidebar-layout.md +258 -0
- package/skills/app-shell-patterns/references/components/sidebar-section.md +114 -0
- package/skills/app-shell-patterns/references/components/spinner.md +81 -0
- package/skills/app-shell-patterns/references/components/table.md +188 -0
- package/skills/app-shell-patterns/references/components/tabs.md +158 -0
- package/skills/app-shell-patterns/references/components/textarea.md +122 -0
- package/skills/app-shell-patterns/references/components/timeline.md +317 -0
- package/skills/app-shell-patterns/references/components/toolbar.md +138 -0
- package/skills/app-shell-patterns/references/components/tooltip.md +144 -0
- package/skills/app-shell-patterns/references/components/with-guard.md +445 -0
- package/skills/app-shell-patterns/references/concepts/authentication.md +301 -0
- package/skills/app-shell-patterns/references/concepts/custom-components.md +59 -0
- package/skills/app-shell-patterns/references/concepts/file-based-routing.md +356 -0
- package/skills/app-shell-patterns/references/{fundamental → concepts}/graphql.md +31 -18
- package/skills/app-shell-patterns/references/concepts/modules-and-resources.md +229 -0
- package/skills/app-shell-patterns/references/concepts/routing-navigation.md +425 -0
- package/skills/app-shell-patterns/references/concepts/sidebar-navigation.md +420 -0
- package/skills/app-shell-patterns/references/concepts/styling-theming.md +785 -0
- package/skills/app-shell-patterns/references/migrations.md +55 -0
- package/skills/app-shell-patterns/references/pages/{detail.md → document-detail.md} +10 -33
- package/skills/app-shell-patterns/references/patterns/form-composer.md +43 -45
- package/skills/app-shell-patterns/references/patterns/form-modal.md +37 -92
- package/skills/app-shell-patterns/references/patterns/form-sectioned.md +12 -38
- package/skills/app-shell-patterns/references/patterns/form-single-page.md +11 -32
- package/skills/app-shell-patterns/references/patterns/form-wizard.md +12 -53
- package/skills/app-shell-patterns/references/patterns/interaction-confirm.md +11 -27
- package/skills/app-shell-patterns/references/patterns/interaction-multi-select.md +136 -139
- package/skills/app-shell-patterns/references/patterns/interaction-toast.md +10 -34
- package/skills/app-shell-patterns/references/patterns/list-dense-scan.md +13 -88
- package/skills/app-shell-patterns/references/fundamental/components.md +0 -1009
- package/skills/app-shell-patterns/references/fundamental/design-system.md +0 -440
|
@@ -0,0 +1,1168 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: DataTable
|
|
3
|
+
description: Compound data table component with sortable columns, filter chips, cursor-based pagination, row actions, and multi-row selection
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
<!-- GENERATED by docs-kit from docs-src/components/data-table.docs.outline.md — do not edit by hand. -->
|
|
7
|
+
|
|
8
|
+
# DataTable
|
|
9
|
+
|
|
10
|
+
`DataTable` is a compound component for displaying collections of records. It integrates with the collection variable hooks (`useCollectionVariables`) to drive sorting, filtering, and cursor-based pagination through a GraphQL API.
|
|
11
|
+
|
|
12
|
+
## Import
|
|
13
|
+
|
|
14
|
+
```tsx
|
|
15
|
+
import {
|
|
16
|
+
DataTable,
|
|
17
|
+
useDataTable,
|
|
18
|
+
useDataTableContext,
|
|
19
|
+
useCollectionVariables,
|
|
20
|
+
createColumnHelper,
|
|
21
|
+
type Column,
|
|
22
|
+
type DataTableData,
|
|
23
|
+
type DataTableRootProps,
|
|
24
|
+
type DataTablePaginationProps,
|
|
25
|
+
type DataTableAction,
|
|
26
|
+
type RowAction,
|
|
27
|
+
type RowSelectionOptions,
|
|
28
|
+
type SelectionAction,
|
|
29
|
+
type UseDataTableOptions,
|
|
30
|
+
type UseDataTableReturn,
|
|
31
|
+
type MetadataFieldOptions,
|
|
32
|
+
type DataTableContextValue,
|
|
33
|
+
} from "@tailor-platform/app-shell";
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Basic Usage
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import { gql, useQuery } from "urql";
|
|
40
|
+
import {
|
|
41
|
+
DataTable,
|
|
42
|
+
useDataTable,
|
|
43
|
+
useCollectionVariables,
|
|
44
|
+
createColumnHelper,
|
|
45
|
+
} from "@tailor-platform/app-shell";
|
|
46
|
+
|
|
47
|
+
const LIST_JOURNALS = gql`
|
|
48
|
+
query ListJournals(
|
|
49
|
+
$after: String
|
|
50
|
+
$before: String
|
|
51
|
+
$first: Int
|
|
52
|
+
$last: Int
|
|
53
|
+
$order: [JournalOrderInput]
|
|
54
|
+
$query: JournalQueryInput
|
|
55
|
+
) {
|
|
56
|
+
journals(
|
|
57
|
+
after: $after
|
|
58
|
+
before: $before
|
|
59
|
+
first: $first
|
|
60
|
+
last: $last
|
|
61
|
+
order: $order
|
|
62
|
+
query: $query
|
|
63
|
+
) {
|
|
64
|
+
edges {
|
|
65
|
+
node {
|
|
66
|
+
id
|
|
67
|
+
contents
|
|
68
|
+
authorID
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
pageInfo {
|
|
72
|
+
endCursor
|
|
73
|
+
hasNextPage
|
|
74
|
+
hasPreviousPage
|
|
75
|
+
startCursor
|
|
76
|
+
}
|
|
77
|
+
total
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
`;
|
|
81
|
+
|
|
82
|
+
type Journal = { id: string; contents: string; authorID: string };
|
|
83
|
+
|
|
84
|
+
const { column } = createColumnHelper<Journal>();
|
|
85
|
+
|
|
86
|
+
const columns = [
|
|
87
|
+
column({
|
|
88
|
+
label: "ID",
|
|
89
|
+
render: (row) => row.id,
|
|
90
|
+
filter: { field: "id", type: "uuid" },
|
|
91
|
+
}),
|
|
92
|
+
column({
|
|
93
|
+
label: "Author",
|
|
94
|
+
render: (row) => row.authorID,
|
|
95
|
+
sort: { field: "authorID", type: "string" },
|
|
96
|
+
filter: { field: "authorID", type: "string" },
|
|
97
|
+
}),
|
|
98
|
+
column({
|
|
99
|
+
label: "Contents",
|
|
100
|
+
render: (row) => row.contents,
|
|
101
|
+
filter: { field: "contents", type: "string" },
|
|
102
|
+
}),
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
function JournalsPage() {
|
|
106
|
+
const { variables, control } = useCollectionVariables({
|
|
107
|
+
params: { pageSize: 20 },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const [result] = useQuery({
|
|
111
|
+
query: LIST_JOURNALS,
|
|
112
|
+
variables: {
|
|
113
|
+
...variables.pagination,
|
|
114
|
+
query: variables.query,
|
|
115
|
+
order: variables.order,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const table = useDataTable({
|
|
120
|
+
columns,
|
|
121
|
+
data: result.data
|
|
122
|
+
? {
|
|
123
|
+
rows: result.data.journals.edges.map((e) => e.node),
|
|
124
|
+
pageInfo: result.data.journals.pageInfo,
|
|
125
|
+
total: result.data.journals.total,
|
|
126
|
+
}
|
|
127
|
+
: undefined,
|
|
128
|
+
loading: result.fetching,
|
|
129
|
+
control,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<DataTable.Root value={table}>
|
|
134
|
+
<DataTable.Toolbar>
|
|
135
|
+
<DataTable.Filters />
|
|
136
|
+
</DataTable.Toolbar>
|
|
137
|
+
<DataTable.Table />
|
|
138
|
+
<DataTable.Footer>
|
|
139
|
+
<DataTable.Pagination pageSizeOptions={[10, 20, 50]} />
|
|
140
|
+
</DataTable.Footer>
|
|
141
|
+
</DataTable.Root>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Sub-components
|
|
147
|
+
|
|
148
|
+
`DataTable` is a namespace object. All sub-components read state from `DataTable.Root` via context.
|
|
149
|
+
|
|
150
|
+
| Sub-component | Description |
|
|
151
|
+
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
152
|
+
| `DataTable.Root` | Context provider. Wraps all other sub-components. Required. |
|
|
153
|
+
| `DataTable.Table` | Renders the `<table>` with headers and body. Required. |
|
|
154
|
+
| `DataTable.Toolbar` | DataTable-specific toolbar container. Optional; pass `columnSettings` for the built-in Columns control. |
|
|
155
|
+
| `DataTable.ColumnSettings` | The placeable Columns control (show/hide + reorder + pin). Use it in a generic [`Toolbar`](./toolbar.md) for custom layouts. |
|
|
156
|
+
| `DataTable.Filters` | Add-filter panel + active filter chips, auto-generated from column filter configs. Requires `control` from `useCollectionVariables`. |
|
|
157
|
+
| `DataTable.Footer` | Footer container for pagination and other footer content. Optional. Becomes the bulk-action bar while rows are selected — see [Selection actions](#selection-actions). |
|
|
158
|
+
| `DataTable.Pagination` | Pre-built pagination controls with optional row count and selection info. Requires `control` from `useCollectionVariables`. Place inside `DataTable.Footer`. |
|
|
159
|
+
|
|
160
|
+
### `DataTable.Root` Props
|
|
161
|
+
|
|
162
|
+
| Prop | Type | Description |
|
|
163
|
+
| ----------- | -------------------------- | -------------------------------------------- |
|
|
164
|
+
| `value` | `UseDataTableReturn<TRow>` | Return value of `useDataTable()`. Required. |
|
|
165
|
+
| `children` | `ReactNode` | Sub-components to render inside the root. |
|
|
166
|
+
| `className` | `string` | Additional CSS class for the root container. |
|
|
167
|
+
|
|
168
|
+
### `DataTable.Toolbar` Props
|
|
169
|
+
|
|
170
|
+
| Prop | Type | Default | Description |
|
|
171
|
+
| ---------------- | ----------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
172
|
+
| `children` | `ReactNode` | — | Toolbar content, stacked on the left. |
|
|
173
|
+
| `columnSettings` | `boolean` | `false` | Render the built-in Columns control at the top-right. Use [`Toolbar`](./toolbar.md) with `DataTable.ColumnSettings` for new custom layouts. |
|
|
174
|
+
| `className` | `string` | — | Additional CSS class for the toolbar container. |
|
|
175
|
+
|
|
176
|
+
For new layouts, use the generic [`Toolbar`](./toolbar.md).
|
|
177
|
+
|
|
178
|
+
### `DataTable.Filters` Props
|
|
179
|
+
|
|
180
|
+
| Prop | Type | Default | Description |
|
|
181
|
+
| ------------- | --------------------------- | ------- | ---------------------------------------------------------------------------------------------- |
|
|
182
|
+
| `slot` | `"all" \| "chips" \| "add"` | `"all"` | Which part to render (see below). |
|
|
183
|
+
| `addIconOnly` | `boolean` | `true` | Render the **Add filter** trigger as an icon-only button (the label becomes its `aria-label`). |
|
|
184
|
+
| `className` | `string` | — | Additional CSS class for the filters container. |
|
|
185
|
+
|
|
186
|
+
By default `DataTable.Filters` renders the active filter chips plus the **Add filter** trigger together. The `slot` prop lets you split them across a custom toolbar layout:
|
|
187
|
+
|
|
188
|
+
- `"all"` — chips + the **Add filter** trigger (default).
|
|
189
|
+
- `"chips"` — only the active chips (renders nothing when there are none).
|
|
190
|
+
- `"add"` — only the **Add filter** trigger.
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
// Add filter in a header row (with tabs, etc.); chips on the row below.
|
|
194
|
+
<Toolbar.Root>
|
|
195
|
+
<Toolbar.Row justify="between" aria-label="List controls">
|
|
196
|
+
<Toolbar.Group>
|
|
197
|
+
<MyViewTabs />
|
|
198
|
+
</Toolbar.Group>
|
|
199
|
+
<Toolbar.Group>
|
|
200
|
+
<DataTable.Filters slot="add" />
|
|
201
|
+
</Toolbar.Group>
|
|
202
|
+
</Toolbar.Row>
|
|
203
|
+
<Toolbar.Row aria-label="Active filters">
|
|
204
|
+
<Toolbar.Group>
|
|
205
|
+
<DataTable.Filters slot="chips" />
|
|
206
|
+
</Toolbar.Group>
|
|
207
|
+
</Toolbar.Row>
|
|
208
|
+
</Toolbar.Root>
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### `DataTable.Pagination` Props
|
|
212
|
+
|
|
213
|
+
| Prop | Type | Default | Description |
|
|
214
|
+
| ----------------- | ---------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
215
|
+
| `pageSizeOptions` | `number[]` | — | Available page-size options. When provided, a page-size switcher is rendered. The First button is always shown, even when the backend omits `total`; the Last button is shown only when the backend returns a `total` count. |
|
|
216
|
+
|
|
217
|
+
`DataTable.Pagination` automatically displays a row count and selection info text on the left side of the pagination bar based on context state:
|
|
218
|
+
|
|
219
|
+
| Condition | Displayed text |
|
|
220
|
+
| ----------------------------------------- | ------------------------ |
|
|
221
|
+
| `total` is provided | `X row(s)` |
|
|
222
|
+
| Rows selected and `total` is provided | `Y of X row(s) selected` |
|
|
223
|
+
| Rows selected and `total` is not provided | `Y row(s) selected` |
|
|
224
|
+
| No selection enabled and no `total` | _(nothing displayed)_ |
|
|
225
|
+
|
|
226
|
+
Row selection is enabled by providing `rowSelection` or `onSelectionChange` to `useDataTable`. The `total` value comes from `DataTableData.total`. While the footer shows the bulk-action bar (see [Selection actions](#selection-actions)), the bar owns the selection count and this text is omitted.
|
|
227
|
+
|
|
228
|
+
When pagination changes page or page size, `DataTable.Table` resets its own scroll container to the top automatically. That applies whether navigation comes from the built-in `DataTable.Pagination` or from custom controls using the same table context.
|
|
229
|
+
|
|
230
|
+
## Column pinning, visibility & ordering
|
|
231
|
+
|
|
232
|
+
- **Pin** a column with `pin: "left" | "right"`. Pinned columns stay visible during horizontal scroll; the selection and expand columns auto-pin left and the row-actions column auto-pins right. A subtle shadow appears at the frozen edge once the table is scrolled under it. Sticky offsets are measured from the rendered layout, so a `width` isn't required — but setting `width` on pinned columns is recommended so their size stays stable as content changes.
|
|
233
|
+
- **Column settings.** `DataTable.ColumnSettings` renders a popover to show/hide columns, reorder them (drag), and change pinning by dragging a column between the **Fixed left**, **Scrollable**, and **Fixed right** zones. Place it in a generic [`Toolbar`](./toolbar.md), or pass `columnSettings` to `DataTable.Toolbar` for its fixed top-right placement.
|
|
234
|
+
- **Persistence.** Pass a stable, **unique** `tableId` to persist each user's column layout (visibility, order, pinning) to `localStorage` (key `as:data-table:v1:<tableId>`). This is a per-user preference — it is deliberately **not** stored in the URL like filters/sort/pagination, so it survives reloads and isn't reset by shared/filtered links. Omit `tableId` for in-memory-only layout (state simply isn't persisted). Two tables mounted with the same `tableId` share one storage key and overwrite each other — use a unique id per table (e.g. `<route>:<entity>`); a dev-mode warning fires on duplicates.
|
|
235
|
+
|
|
236
|
+
```tsx
|
|
237
|
+
const table = useDataTable<Order>({
|
|
238
|
+
columns, // e.g. [{ id: "ref", label: "Ref", width: 140, pin: "left" }, ...]
|
|
239
|
+
data,
|
|
240
|
+
tableId: "orders-list",
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
<DataTable.Root value={table}>
|
|
244
|
+
<Toolbar.Root>
|
|
245
|
+
<Toolbar.Row justify="between" aria-label="Order table controls">
|
|
246
|
+
<Toolbar.Group>
|
|
247
|
+
<DataTable.Filters />
|
|
248
|
+
</Toolbar.Group>
|
|
249
|
+
<Toolbar.Group>
|
|
250
|
+
<DataTable.ColumnSettings />
|
|
251
|
+
</Toolbar.Group>
|
|
252
|
+
</Toolbar.Row>
|
|
253
|
+
</Toolbar.Root>
|
|
254
|
+
<DataTable.Table />
|
|
255
|
+
</DataTable.Root>;
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## Context menus
|
|
259
|
+
|
|
260
|
+
`DataTable.Table` adds right-click context menus to both headers and cells:
|
|
261
|
+
|
|
262
|
+
- **Headers** can copy the label, pin/unpin, sort, reset sort, and hide the column.
|
|
263
|
+
- **Cells** can copy the value, copy `"[header] [value]"`, and add a filter from that cell's value.
|
|
264
|
+
|
|
265
|
+
### Behavior contract
|
|
266
|
+
|
|
267
|
+
`DataTable` treats column configuration as two layers:
|
|
268
|
+
|
|
269
|
+
- **Presentation** — `column.header` and `column.render` control what is rendered.
|
|
270
|
+
- **Behavior/data** — `column.label`, `column.id`, `column.accessor`, `column.sort`, `column.filter`, and collection `control` drive built-in behavior such as sorting, truncation tooltips, persistence, and context-menu actions.
|
|
271
|
+
|
|
272
|
+
This means built-in interactions do **not** infer meaning from rendered DOM content. If a custom-rendered column should still participate in built-in copy/filter/tooltip behavior, give it an `id` or `accessor` that exposes the raw value.
|
|
273
|
+
|
|
274
|
+
### Custom headers
|
|
275
|
+
|
|
276
|
+
Header context menus still work when `column.header` is custom, because the menu wraps the rendered header content from the outside.
|
|
277
|
+
|
|
278
|
+
When a column is sortable (`column.sort` is set and collection `control` is available), the **Sort column** submenu still appears in the context menu. However, left-click sorting remains the custom header's responsibility: call `ctx.activateSort()` from your custom header if you want click-to-sort behavior in the header itself.
|
|
279
|
+
|
|
280
|
+
### Custom cell renderers
|
|
281
|
+
|
|
282
|
+
Cell context menus need a raw value for copy and filter actions. They resolve it in this order:
|
|
283
|
+
|
|
284
|
+
1. `column.accessor(row)`
|
|
285
|
+
2. `row[column.id]`
|
|
286
|
+
3. the return value of `column.render(row)`, **only when it is a primitive** (`string`, `number`, `boolean`, or `bigint`)
|
|
287
|
+
|
|
288
|
+
If a custom cell renderer only returns JSX and does not provide `id` or `accessor`, the table cannot recover the underlying value. In that case:
|
|
289
|
+
|
|
290
|
+
- **Copy value** is disabled
|
|
291
|
+
- **Copy header and value** is disabled
|
|
292
|
+
- **Add filter** is omitted
|
|
293
|
+
|
|
294
|
+
For custom-rendered columns that should participate in copy/filter actions, set `id` or `accessor` explicitly.
|
|
295
|
+
|
|
296
|
+
### Filter behavior
|
|
297
|
+
|
|
298
|
+
The cell **Add filter** submenu reuses the same collection `control` as `DataTable.Filters`, so new filters flow through the existing chips, query variables, URL sync, and persistence you already have.
|
|
299
|
+
|
|
300
|
+
The submenu is intentionally narrower than the full filter editor:
|
|
301
|
+
|
|
302
|
+
- it only appears when the column has `filter` config and `useDataTable` received a `control`
|
|
303
|
+
- it only offers **single-value** operators from that column's configured operator list
|
|
304
|
+
- multi-value operators such as `between` are excluded
|
|
305
|
+
- enum filters use the **raw enum value**, not the rendered label
|
|
306
|
+
|
|
307
|
+
## Expandable rows
|
|
308
|
+
|
|
309
|
+
Pass `rowExpansion` to `useDataTable` and each row gets a chevron that reveals a detail panel beneath it. Providing the option is what enables the feature — a dedicated chevron column is added at the left edge (auto-pinned left, after the selection column) and the detail row renders automatically. There is nothing new to compose in JSX.
|
|
310
|
+
|
|
311
|
+
The parts are grouped rather than flat so the type system rejects incoherent setups: a `getLabel` or `canExpand` with no `render`, or `expandedIds` without `onChange`, are compile errors rather than silently inert configurations.
|
|
312
|
+
|
|
313
|
+
```tsx
|
|
314
|
+
const table = useDataTable<Order>({
|
|
315
|
+
columns,
|
|
316
|
+
data,
|
|
317
|
+
control,
|
|
318
|
+
rowExpansion: {
|
|
319
|
+
render: (row) => <OrderLineItems orderId={row.id} />,
|
|
320
|
+
canExpand: (row) => row.lineItemCount > 0,
|
|
321
|
+
getLabel: (row) => row.orderNumber,
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
- **Rows must have an `id`.** Expansion is keyed by `row.id`, the same constraint as row selection. Rows without one render **no chevron** (not a disabled one) — a row must never be un-toggleable.
|
|
327
|
+
- **`canExpand`** suppresses the chevron per row (e.g. an order with no line items). The cell is still rendered, empty, so the column count stays consistent. The predicate gates the panel in **both** directions: a row whose result flips to `false` while open closes immediately, and an id sitting in `expandedIds` never opens a panel for a row the predicate rejects. That matters when restoring `expandedIds` from a URL or storage — the excluded row may have no detail data to render at all. Its id stays in `expandedIds` but is inert; `collapseAllRows()` clears it.
|
|
328
|
+
- **`getLabel`** returns a **bare identifier** — `"INV-1001"`, not `"Expand row INV-1001"`. The built-in i18n labels compose it into the trigger's accessible name (`"Expand row INV-1001"`) and the panel's (`"INV-1001 details"`), so English and Japanese word order both stay correct. Without it, the generic "Expand row" / "Row details" strings are used — set it on any table with more than a couple of rows.
|
|
329
|
+
- **`onClickRow` is unaffected.** The chevron lives in its own column and stops click propagation, so row-level navigation keeps working.
|
|
330
|
+
- **Expansion survives page changes.** Ids of rows that are no longer on the page simply don't render. Call `collapseAllRows()` to reset.
|
|
331
|
+
- **Multiple rows can be open at once.** There is no accordion / single-open mode.
|
|
332
|
+
|
|
333
|
+
### Controlled mode
|
|
334
|
+
|
|
335
|
+
Pass `expandedIds` to own the state yourself; internal state is then never written and you update the array from `onChange`. The two must be passed together — the type rejects either alone.
|
|
336
|
+
|
|
337
|
+
```tsx
|
|
338
|
+
const [expandedIds, setExpandedIds] = useState<string[]>([]);
|
|
339
|
+
|
|
340
|
+
const table = useDataTable<Order>({
|
|
341
|
+
columns,
|
|
342
|
+
data,
|
|
343
|
+
rowExpansion: {
|
|
344
|
+
render: (row) => <OrderLineItems orderId={row.id} />,
|
|
345
|
+
expandedIds,
|
|
346
|
+
onChange: setExpandedIds,
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
`useDataTable` also returns `expandedIds`, `isRowExpanded(row)`, `toggleRowExpansion(row)`, and `collapseAllRows()`. The last two are `undefined` when `rowExpansion` is not provided, and both keep a stable identity across renders, so they are safe to list in an effect's dependency array:
|
|
352
|
+
|
|
353
|
+
```tsx
|
|
354
|
+
// Collapse everything when the page changes.
|
|
355
|
+
useEffect(() => {
|
|
356
|
+
collapseAllRows?.();
|
|
357
|
+
}, [collapseAllRows, currentPage]);
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
`collapseAllRows()` is a no-op when nothing is open — it neither writes state nor fires `onExpandedChange`.
|
|
361
|
+
|
|
362
|
+
**Batching caveat.** Each toggle derives the next array from the current value of `expandedIds`, not from a functional update. Two toggles dispatched before your state commits both read the same base, so the first is lost. This matters when `expandedIds` lives behind an async store (Redux/Zustand middleware, a debounced URL sync, a `startTransition`), or when looping `toggleRowExpansion` over many rows to build an "expand all". Compute such updates yourself and set `expandedIds` directly rather than driving them through repeated toggles.
|
|
363
|
+
|
|
364
|
+
In uncontrolled mode `onChange` is still allowed on its own, as a notification.
|
|
365
|
+
|
|
366
|
+
### Accessibility
|
|
367
|
+
|
|
368
|
+
The trigger is a native `<button>`, so Enter/Space activation and the focus ring come for free, and it carries `aria-expanded` — that is what announces the state change on activation. The panel is a `role="region"` with an accessible name, and it sits immediately after its trigger in DOM order, so forward-tabbing reaches it next. Collapsing while focus is inside the panel hands focus back to the trigger rather than dropping it to `<body>`. The chevron's rotation and the panel's reveal both respect `prefers-reduced-motion`. Panel content wider than the viewport scrolls horizontally within the panel, and on a horizontally scrolled table the panel stays pinned to the left edge of the scrollport.
|
|
369
|
+
|
|
370
|
+
**Expand/collapse is animated** — the panel reveals by transitioning `grid-template-rows` from `0fr` to `1fr`, so it animates to its exact natural height whatever you render, with no height cap to clip tall content. One consequence worth knowing when writing tests against a table: `aria-expanded` flips immediately, but the detail row stays mounted for the ~300ms collapse (marked `data-state="closed"` meanwhile) before it is removed. Assert its removal with `waitFor` rather than synchronously. Under `prefers-reduced-motion` the transition is skipped, but the unmount is still deferred by the same interval.
|
|
371
|
+
|
|
372
|
+
**Known limitation — row count.** Detail rows are real `<tr>` elements, so a screen reader counts them: ten records with two expanded announces as twelve rows. Fixing this needs `aria-rowcount` plus explicit `aria-rowindex` on every row (with detail rows sharing their parent's index) and correct interaction with pagination; `role="presentation"` on the detail row would fix the count but remove the panel from screen-reader table navigation. Neither is implemented.
|
|
373
|
+
|
|
374
|
+
## Row selection
|
|
375
|
+
|
|
376
|
+
Pass `rowSelection` to `useDataTable` to enable the checkbox column. Rows must have a string or number `id`; selected ids are their string-normalized values, and rows without an id are excluded. An empty configuration (`rowSelection: {}`) enables uncontrolled selection without a notification callback. Bulk actions belong in `rowSelection.actions`; `rowSelection: { actions: [] }` still enables checkboxes, but does not show the bulk-action bar.
|
|
377
|
+
|
|
378
|
+
### `RowSelectionOptions`
|
|
379
|
+
|
|
380
|
+
| Property | Type | Description |
|
|
381
|
+
| -------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
382
|
+
| `selectedIds` | `string[]` | Controlled selected ids. Requires `onChange` and cannot be combined with `defaultSelectedIds`. |
|
|
383
|
+
| `defaultSelectedIds` | `string[]` | Initial selected ids in uncontrolled mode. Later changes are ignored. |
|
|
384
|
+
| `onChange` | `(ids: string[]) => void` | Notifies user-driven selection changes; required in controlled mode and optional in uncontrolled mode. |
|
|
385
|
+
| `actions` | `SelectionAction[]` | Optional bulk actions shown in `DataTable.Footer` while ids are selected. Omitted or empty arrays show no bar. |
|
|
386
|
+
|
|
387
|
+
### Controlled selection
|
|
388
|
+
|
|
389
|
+
Provide `selectedIds` and `onChange` together to own the selection. The `RowSelectionOptions` type requires `onChange` in controlled mode and rejects combining `selectedIds` with `defaultSelectedIds`.
|
|
390
|
+
|
|
391
|
+
```tsx
|
|
392
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
393
|
+
|
|
394
|
+
const table = useDataTable<Order>({
|
|
395
|
+
columns,
|
|
396
|
+
data,
|
|
397
|
+
rowSelection: { selectedIds, onChange: setSelectedIds },
|
|
398
|
+
});
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
Commands such as `toggleRowSelection(row)` request a new array through `onChange`; the rendered selection changes only when the owner updates `selectedIds`. Each command derives from the current committed value, so multiple commands before the owner commits share the same base. Compute batched changes yourself and set `selectedIds` directly instead of issuing repeated commands.
|
|
402
|
+
|
|
403
|
+
### Uncontrolled selection
|
|
404
|
+
|
|
405
|
+
Omit `selectedIds` to let the table own selection state. `defaultSelectedIds` initializes it only when the hook mounts; later changes to that default do not reset selection. `onChange` is optional.
|
|
406
|
+
|
|
407
|
+
```tsx
|
|
408
|
+
const table = useDataTable<Order>({
|
|
409
|
+
columns,
|
|
410
|
+
data,
|
|
411
|
+
rowSelection: { defaultSelectedIds: ["INV-1001"] },
|
|
412
|
+
});
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
Keep the controlled/uncontrolled mode stable while mounted. Callbacks notify user-driven selection changes, not mounting or external `selectedIds` updates. `onSelectionChange` is deprecated but remains supported for compatibility. Replace `onSelectionChange: handleSelectionChange` with `rowSelection: { onChange: handleSelectionChange }` to keep uncontrolled notification behavior. `rowSelection.onChange` takes precedence when both callbacks are supplied, and `onSelectionChange` is used when the new configuration has no callback.
|
|
416
|
+
|
|
417
|
+
Selection spans pages. The header checkbox adds or removes only the current page's rows, preserving ids selected on other pages; `clearSelection()` clears every page. Selection stores IDs only, not row objects. Both `selectedIds` and bulk-action payloads include all selected ids, including ids whose rows have not been loaded. Fetch any records required for eligibility checks or execution in the consumer's ID-based workflow.
|
|
418
|
+
|
|
419
|
+
## Selection actions
|
|
420
|
+
|
|
421
|
+
Pass `rowSelection.actions` to `useDataTable` and, while at least one id is selected, `DataTable.Footer` becomes a bulk-action bar: the selection count, your actions, and a **Clear** button, with the footer's own children (usually `DataTable.Pagination`) kept alongside. `rowSelection: { actions }` enables uncontrolled selection without an `onChange` callback. Omitted or empty `actions` show no bar, while the `rowSelection` object still enables checkboxes. There is nothing new to compose in JSX — just keep `DataTable.Footer` in the tree.
|
|
422
|
+
|
|
423
|
+
```tsx
|
|
424
|
+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
425
|
+
const [deleteTargetIds, setDeleteTargetIds] = useState<string[] | null>(null);
|
|
426
|
+
|
|
427
|
+
const table = useDataTable<Vendor>({
|
|
428
|
+
columns,
|
|
429
|
+
data,
|
|
430
|
+
control,
|
|
431
|
+
rowSelection: {
|
|
432
|
+
selectedIds,
|
|
433
|
+
onChange: setSelectedIds,
|
|
434
|
+
actions: [
|
|
435
|
+
{
|
|
436
|
+
id: "delete",
|
|
437
|
+
label: "Delete",
|
|
438
|
+
variant: "destructive",
|
|
439
|
+
onClick: (ids) => setDeleteTargetIds([...ids]),
|
|
440
|
+
},
|
|
441
|
+
],
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
return (
|
|
446
|
+
<>
|
|
447
|
+
<DataTable.Root value={table}>
|
|
448
|
+
<DataTable.Table />
|
|
449
|
+
<DataTable.Footer>
|
|
450
|
+
<DataTable.Pagination />
|
|
451
|
+
</DataTable.Footer>
|
|
452
|
+
</DataTable.Root>
|
|
453
|
+
|
|
454
|
+
{deleteTargetIds !== null && (
|
|
455
|
+
<DeleteVendorsDialog
|
|
456
|
+
ids={deleteTargetIds}
|
|
457
|
+
onCancel={() => setDeleteTargetIds(null)}
|
|
458
|
+
onDeleted={(deletedIds: string[]) => {
|
|
459
|
+
const deleted = new Set(deletedIds);
|
|
460
|
+
setSelectedIds((current) => current.filter((id) => !deleted.has(id)));
|
|
461
|
+
setDeleteTargetIds(null);
|
|
462
|
+
}}
|
|
463
|
+
/>
|
|
464
|
+
)}
|
|
465
|
+
</>
|
|
466
|
+
);
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
`DeleteVendorsDialog` is a consumer-owned component, not an AppShell component. It can fetch the target records, explain eligibility (for example, “Three of five selected vendors can be deleted; two are in use”), confirm, and execute. Backend validation remains authoritative; `onDeleted` reports only ids actually deleted. Removing those ids from the **current** selection preserves ineligible records and records selected after the dialog opened. Cancellation or failure leaves selection unchanged.
|
|
470
|
+
|
|
471
|
+
- **`onClick` receives a click-time snapshot of all selected ids.** The array is a copy, in selection order, including ids whose rows have not been loaded. Page visits and loaded-row availability do not change the target. Selection actions have no row-based `canApply`, automatic eligible count, or eligibility-based disabled state; the consumer owns eligibility checks, required fetching, confirmation UI, and execution. Row actions can still use `canApply` for their concrete row.
|
|
472
|
+
- **Return the promise from `onClick`.** While it is pending, the bar disables every action and shows a spinner on the running one, so a slow request can't be fired twice. Neither success nor rejection changes selection; there is no `keepSelection` option. A rejection is logged as `[DataTable] Selection action "…" failed`; showing it to the user is up to your handler.
|
|
473
|
+
- **Confirm dialogs can hand a later request back with `run`.** An `onClick` that only opens a dialog returns nothing. Keep the `run` helper and call it from the dialog's confirm button — `await run(deleteVendors(ids))` — to track that request in the bar. This only tracks pending state; it does not clear selection. Clear is disabled while a request is pending, and pending state survives the bar closing and reopening. Use controlled `rowSelection` to remove only successful ids, or call the explicit `clearSelection` helper if clearing every page is intentional.
|
|
474
|
+
- **Selection spans pages.** The header checkbox adds or removes only the current page; **Clear** empties everything.
|
|
475
|
+
- **Three actions stay inline.** The fourth onward collapse into a **More actions** menu, in array order. Put the most frequent first; a destructive action placed last sits safely in the menu.
|
|
476
|
+
- **Confirm destructive actions.** `variant: "destructive"` only styles the action. Open a confirm dialog from `onClick` before deleting — see the [confirm pattern](../patterns/interaction-confirm.md).
|
|
477
|
+
- **The bar stays reachable.** In `<Layout fill>` the footer is already pinned. On a page that scrolls, the bar sticks to the bottom of the viewport while rows are selected and settles back into place at the end of the table. It sticks to the nearest scrolling container: inside a scrolling drawer or panel it rides that container instead, and inside a wrapper that clips its overflow (such as a card with `overflow: hidden`) it simply stays in the footer.
|
|
478
|
+
- **Share presentation with `rowActions`.** Both action types extend the non-generic [`DataTableAction`](#datatableaction) (`id`, `label`, `icon`, `variant`). Keep row predicates on `RowAction<TRow>` and bulk business rules in the consumer's ID-based handler.
|
|
479
|
+
|
|
480
|
+
### Accessibility
|
|
481
|
+
|
|
482
|
+
The bar is a `role="toolbar"` named "Bulk actions", so Arrow, Home, and End move between its controls (see [`Toolbar`](./toolbar.md)) while Tab still reaches each one. The selection count is announced through a polite live region from the first selection on. When the bar closes while focus is inside it — after **Clear**, or an action that clears — focus returns to the header checkbox instead of dropping to `<body>`.
|
|
483
|
+
|
|
484
|
+
## `useDataTable`
|
|
485
|
+
|
|
486
|
+
Creates the table state object to pass to `DataTable.Root`. The hook return value and `useDataTableContext()` both expose the original selection configuration as `rowSelection`, including `rowSelection.actions`. Read the current selection from `selectedIds` and update it through the selection commands.
|
|
487
|
+
|
|
488
|
+
```tsx
|
|
489
|
+
const table = useDataTable({
|
|
490
|
+
columns,
|
|
491
|
+
data,
|
|
492
|
+
loading,
|
|
493
|
+
control,
|
|
494
|
+
});
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
### Options
|
|
498
|
+
|
|
499
|
+
| Option | Type | Description |
|
|
500
|
+
| ------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
501
|
+
| `columns` | `Column<TRow>[]` | Column definitions. Required. |
|
|
502
|
+
| `data` | `DataTableData<TRow> \| undefined` | Fetched data. Pass `undefined` while loading. |
|
|
503
|
+
| `loading` | `boolean` | When `true`, renders a loading skeleton. |
|
|
504
|
+
| `error` | `Error \| null` | When set, renders an error message in the table body. |
|
|
505
|
+
| `control` | `CollectionControl` | Collection control from `useCollectionVariables()`. Required for `DataTable.Pagination` and `DataTable.Filters`. |
|
|
506
|
+
| `onClickRow` | `(row: TRow) => void` | Called when the user clicks a row. Adds a pointer cursor to rows. |
|
|
507
|
+
| `tableId` | `string` | Stable id used to persist per-user column layout (visibility, order, pinning) to `localStorage`. When omitted, column layout is in-memory only and resets on reload. |
|
|
508
|
+
| `rowActions` | `RowAction<TRow>[]` | Per-row action items rendered in a kebab-menu column. The column is omitted when empty or not provided. |
|
|
509
|
+
| `rowSelection` | `RowSelectionOptions` | Controlled `selectedIds` + `onChange`, or uncontrolled `defaultSelectedIds` + optional `onChange`, with optional `actions`. Providing even `{}` enables checkboxes. See [Row selection](#row-selection). |
|
|
510
|
+
| `onSelectionChange` | `(ids: string[]) => void` | **Deprecated:** use `rowSelection.onChange`. Still supported for compatibility; enables checkboxes and is used when the new callback is absent. |
|
|
511
|
+
| `rowExpansion` | `RowExpansionOptions<TRow>` | Expandable detail rows: `render`, plus optional `canExpand` / `getLabel`, and `expandedIds` + `onChange` together for controlled mode. See [Expandable rows](#expandable-rows). |
|
|
512
|
+
| `sort` | `false \| { multiple?: boolean }` | Sort behaviour. `false` disables sorting entirely. `{ multiple: true }` enables multi-column sorting. Omit or pass `{}` for single-column sort (default). |
|
|
513
|
+
|
|
514
|
+
### `DataTableData`
|
|
515
|
+
|
|
516
|
+
| Property | Type | Description |
|
|
517
|
+
| ---------- | ---------------- | -------------------------------------------------------------------- |
|
|
518
|
+
| `rows` | `TRow[]` | Row data to display. |
|
|
519
|
+
| `pageInfo` | `PageInfo` | Cursor pagination info from the API. |
|
|
520
|
+
| `total` | `number \| null` | Total record count. Used for First/Last navigation and page counter. |
|
|
521
|
+
|
|
522
|
+
## `Column`
|
|
523
|
+
|
|
524
|
+
A column definition passed to `useDataTable`. `Column<TRow>` is a discriminated union on `type` — the shape of `typeOptions` is narrowed per branch, so mismatches are compile errors rather than silent runtime no-ops.
|
|
525
|
+
|
|
526
|
+
### Shared fields
|
|
527
|
+
|
|
528
|
+
| Property | Type | Description |
|
|
529
|
+
| ---------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
530
|
+
| `label` | `string` | Column header text. Omit for icon-only columns. |
|
|
531
|
+
| `header` | `(ctx: HeaderRenderContext) => ReactNode` | Custom header renderer. Presentation only. When omitted, the built-in header renders `label` and owns the sort button/indicator. When provided, the return value replaces the built-in header entirely; sortable custom headers receive `sortDirection` and `activateSort()` via `ctx` and must render their own click surface for left-click sorting. The header context menu still uses the column metadata (`label`, `sort`, etc.). |
|
|
532
|
+
| `render` | `(row: TRow) => ReactNode` | Renders the cell content. Presentation only. Optional — overrides the built-in `type` renderer when set. Built-in behaviors that need the raw value (truncate tooltip, cell copy/filter context-menu actions) still resolve it from `accessor` first, then `row[col.id]`, and only fall back to the return value of `render(row)` when it is a primitive (`string`, `number`, `boolean`, or `bigint`). |
|
|
533
|
+
| `id` | `string` | Stable identifier for column visibility, persisted layout state, raw-value fallback (`row[col.id]`), and the React key. Falls back to `label` when omitted. Set this explicitly when `label` is absent, not unique, or when a custom-rendered column should still participate in built-in cell behaviors that need a raw value. |
|
|
534
|
+
| `width` | `number` | Fixed column width in pixels. Optional. |
|
|
535
|
+
| `pin` | `"left" \| "right"` | Freezes the column to that edge so it stays visible during horizontal scroll (the default; the user can override it via the toolbar's column-settings control). Sticky offsets are measured from the rendered layout, so `width` isn't required — but setting `width` on pinned columns is recommended for stable sizing. The selection and expand columns auto-pin left and the row-actions column auto-pin right. |
|
|
536
|
+
| `align` | `"left" \| "right"` | Horizontal alignment. Defaults to `"right"` for `type: "number"` and `type: "money"`; `"left"` otherwise. Pass `"left"` to opt a numeric column out. |
|
|
537
|
+
| `truncate` | `boolean` | Truncate overflowing text with an ellipsis. Wires up an app-shell `<Tooltip>` automatically when the resolved raw cell value is a string or number (`accessor` first, then `row[col.id]`). With `inferColumns`, no explicit `accessor` is needed because `id` is pinned to the field name. Requires another column to anchor the row width (`width` on a neighbor, or a fixed-size column like selection / row actions). |
|
|
538
|
+
| `accessor` | _(narrowed per `type`)_ | Extracts the raw value. This is the primary source of truth for built-in behaviors that need a value independent of presentation (typed rendering, truncate tooltip, cell copy/filter context-menu actions). The return type is narrowed per `type` branch — returning an array is a compile error on all typed columns except `badge`, and returning a plain object is a compile error on all typed columns. Untyped columns (`type` omitted) retain `unknown`. `null` and `undefined` are always allowed. |
|
|
539
|
+
| `sort` | `SortConfig` | Sort configuration. When set, the column participates in the built-in sort behaviors: the default clickable header and the header context-menu sort submenu. Custom headers still need to call `ctx.activateSort()` to opt into left-click sorting. |
|
|
540
|
+
| `filter` | `FilterConfig` | Filter configuration. When set, the column appears as an option in `DataTable.Filters` and can also drive the cell **Add filter** context-menu submenu when collection `control` is available. The cell menu narrows this to single-value operators only. |
|
|
541
|
+
|
|
542
|
+
### `type`-specific fields
|
|
543
|
+
|
|
544
|
+
| `type` | `typeOptions` |
|
|
545
|
+
| ----------- | ------------------------------------------------------------ |
|
|
546
|
+
| _(omitted)_ | _(not allowed; provide `render` to draw the cell)_ |
|
|
547
|
+
| `"text"` | _(not allowed)_ |
|
|
548
|
+
| `"number"` | `NumberCellOptions` |
|
|
549
|
+
| `"money"` | `MoneyCellOptions<TRow>` |
|
|
550
|
+
| `"date"` | `DateCellOptions` |
|
|
551
|
+
| `"badge"` | `BadgeCellOptions` |
|
|
552
|
+
| `"link"` | **Required** — `LinkCellOptions<TRow>` (must include `href`) |
|
|
553
|
+
|
|
554
|
+
## Cell types
|
|
555
|
+
|
|
556
|
+
When `type` is set, the cell is rendered from `accessor(row)` (or `row[id]` when `accessor` is omitted) using a built-in renderer. Pass `render` to override on a per-column basis.
|
|
557
|
+
|
|
558
|
+
```tsx
|
|
559
|
+
column({
|
|
560
|
+
label: "Total",
|
|
561
|
+
accessor: (row) => row.total,
|
|
562
|
+
type: "money",
|
|
563
|
+
typeOptions: { currency: "USD", maxDecimals: 4 },
|
|
564
|
+
});
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
| `type` | Accessor return type | Value handling | Options interface |
|
|
568
|
+
| -------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
569
|
+
| `text` | `string \| number \| boolean \| bigint \| null \| undefined` | `String(value)` — falls back to `—` when nullish. | _(no options)_ |
|
|
570
|
+
| `number` | `number \| null \| undefined` | `Intl.NumberFormat`. `—` for nullish / NaN. | `NumberCellOptions`: `minDecimals`, `maxDecimals`, `locale` |
|
|
571
|
+
| `money` | `number \| null \| undefined` | `Intl.NumberFormat` currency. `—` for nullish. | `MoneyCellOptions<TRow>`: `currency` (string or `(row) => string`), `maxDecimals`, `locale` |
|
|
572
|
+
| `date` | `Date \| string \| number \| null \| undefined` | `Intl.DateTimeFormat`. Accepts `Date`/ISO/epoch. | `DateCellOptions`: `dateFormat` (`"short"` \| `"long"` \| `"datetime"`), `locale` |
|
|
573
|
+
| `badge` | `string \| number \| boolean \| null \| undefined \| Array<string \| number \| boolean>` | `<Badge>` keyed off the stringified value. Arrays render multiple badges. | `BadgeCellOptions`: `badgeVariantMap`, `badgeLabelMap`, `defaultBadgeVariant` (defaults to `"outline-neutral"`), `maxVisible` |
|
|
574
|
+
| `link` | `string \| number \| boolean \| null \| undefined` | app-shell `<Link>` to `typeOptions.href(row)`. | `LinkCellOptions<TRow>`: `href: (row) => string \| null \| undefined` (returning nullish renders plain text; **required**) |
|
|
575
|
+
|
|
576
|
+
Empty values (`null`, `undefined`, `""`) render a muted `—` placeholder for every type. Use `render` for custom empty-state handling.
|
|
577
|
+
|
|
578
|
+
The discriminated-union shape means:
|
|
579
|
+
|
|
580
|
+
```tsx
|
|
581
|
+
// ❌ Compile error — badgeVariantMap is not a money option
|
|
582
|
+
column({ type: "money", accessor: (r) => r.total, typeOptions: { badgeVariantMap: {} } });
|
|
583
|
+
|
|
584
|
+
// ❌ Compile error — link columns must provide typeOptions.href
|
|
585
|
+
column({ type: "link", accessor: (r) => r.title });
|
|
586
|
+
|
|
587
|
+
// ❌ Compile error — text columns reject typeOptions entirely
|
|
588
|
+
column({ type: "text", accessor: (r) => r.title, typeOptions: { locale: "en-US" } });
|
|
589
|
+
|
|
590
|
+
// ❌ Compile error — text/number/money/link accessor cannot return an array or object
|
|
591
|
+
column({ type: "text", accessor: (row) => row.tags }); // row.tags is string[]
|
|
592
|
+
column({ type: "number", accessor: (row) => row.meta }); // row.meta is an object
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
## Adding a typed column
|
|
596
|
+
|
|
597
|
+
Each column type follows the same three-step shape: pick a `type`, point `accessor` at the value, and pass `typeOptions` for the type-specific bits. `label`, `sort`, `filter`, `align`, `width`, and `id` work the same regardless of `type`.
|
|
598
|
+
|
|
599
|
+
### `text` — plain string
|
|
600
|
+
|
|
601
|
+
```tsx
|
|
602
|
+
column({
|
|
603
|
+
label: "Name",
|
|
604
|
+
accessor: (row) => row.name,
|
|
605
|
+
type: "text",
|
|
606
|
+
});
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
- `null` / `undefined` / `""` render a muted `—`.
|
|
610
|
+
- No `typeOptions` are accepted on `text` columns.
|
|
611
|
+
- Omit `type` entirely if you want to keep `render` required for that column.
|
|
612
|
+
|
|
613
|
+
### `number` — locale-formatted number
|
|
614
|
+
|
|
615
|
+
```tsx
|
|
616
|
+
column({
|
|
617
|
+
label: "Stock",
|
|
618
|
+
accessor: (row) => row.stockOnHand,
|
|
619
|
+
type: "number",
|
|
620
|
+
typeOptions: { minDecimals: 0, maxDecimals: 0, locale: "en-US" },
|
|
621
|
+
});
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
- `minDecimals` / `maxDecimals` default to `0`.
|
|
625
|
+
- `maxDecimals` defaults to `minDecimals` when only `minDecimals` is set — pass both for ranges (e.g. `min: 2, max: 4`).
|
|
626
|
+
- `NaN` and `null` render the `—` placeholder.
|
|
627
|
+
|
|
628
|
+
### `money` — currency
|
|
629
|
+
|
|
630
|
+
```tsx
|
|
631
|
+
column({
|
|
632
|
+
label: "Total",
|
|
633
|
+
accessor: (row) => row.total,
|
|
634
|
+
type: "money",
|
|
635
|
+
typeOptions: { currency: "USD" },
|
|
636
|
+
});
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
For mixed-currency tables, read `currency` from the row:
|
|
640
|
+
|
|
641
|
+
```tsx
|
|
642
|
+
column({
|
|
643
|
+
label: "Total",
|
|
644
|
+
accessor: (row) => row.total,
|
|
645
|
+
type: "money",
|
|
646
|
+
typeOptions: {
|
|
647
|
+
currency: (row) => row.currencyCode, // "USD", "JPY", "EUR", …
|
|
648
|
+
maxDecimals: 4, // raise the cap above the currency default
|
|
649
|
+
},
|
|
650
|
+
});
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
- Default `currency` is `"USD"` when omitted or when the accessor returns falsy.
|
|
654
|
+
- Invalid ISO codes fall back to USD (rather than throwing).
|
|
655
|
+
- The minimum decimals always stays at the currency default (2 for USD, 0 for JPY); `maxDecimals` raises the cap without padding with trailing zeros.
|
|
656
|
+
|
|
657
|
+
### `date` — formatted date
|
|
658
|
+
|
|
659
|
+
```tsx
|
|
660
|
+
column({
|
|
661
|
+
label: "Placed",
|
|
662
|
+
accessor: (row) => row.placedAt, // Date | ISO string | epoch ms
|
|
663
|
+
type: "date",
|
|
664
|
+
typeOptions: { dateFormat: "short" }, // "short" | "long" | "datetime"
|
|
665
|
+
});
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
| `dateFormat` | Example output |
|
|
669
|
+
| ------------------- | ---------------------- |
|
|
670
|
+
| `"short"` (default) | `Apr 9, 2026` |
|
|
671
|
+
| `"long"` | `April 9, 2026` |
|
|
672
|
+
| `"datetime"` | `Apr 9, 2026, 3:45 PM` |
|
|
673
|
+
|
|
674
|
+
- Accepts a `Date`, an ISO 8601 string, or epoch milliseconds.
|
|
675
|
+
- Invalid dates render the `—` placeholder.
|
|
676
|
+
|
|
677
|
+
### `badge` — status pill
|
|
678
|
+
|
|
679
|
+
```tsx
|
|
680
|
+
column({
|
|
681
|
+
label: "Status",
|
|
682
|
+
accessor: (row) => row.status,
|
|
683
|
+
type: "badge",
|
|
684
|
+
typeOptions: {
|
|
685
|
+
badgeVariantMap: {
|
|
686
|
+
shipped: "success",
|
|
687
|
+
processing: "outline-warning",
|
|
688
|
+
cancelled: "subtle-error",
|
|
689
|
+
},
|
|
690
|
+
badgeLabelMap: {
|
|
691
|
+
shipped: "Shipped",
|
|
692
|
+
processing: "Processing",
|
|
693
|
+
cancelled: "Cancelled",
|
|
694
|
+
},
|
|
695
|
+
defaultBadgeVariant: "outline-neutral", // unmapped values fall back here
|
|
696
|
+
},
|
|
697
|
+
});
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
- The cell value is stringified before lookup, so `accessor` can return strings, numbers, or booleans.
|
|
701
|
+
- `accessor` may also return an **array** of values — each item is rendered as a separate badge.
|
|
702
|
+
- Unmapped values render with `defaultBadgeVariant` (or `"outline-neutral"`) and the raw stringified value as the label.
|
|
703
|
+
|
|
704
|
+
#### Array badges with overflow
|
|
705
|
+
|
|
706
|
+
Use `maxVisible` to cap the number of badges shown. Extra values are hidden behind a hover popover:
|
|
707
|
+
|
|
708
|
+
```tsx
|
|
709
|
+
column({
|
|
710
|
+
...infer("tags"),
|
|
711
|
+
type: "badge",
|
|
712
|
+
typeOptions: {
|
|
713
|
+
badgeVariantMap: { Premium: "warning", Office: "outline-info" },
|
|
714
|
+
maxVisible: 2,
|
|
715
|
+
},
|
|
716
|
+
});
|
|
717
|
+
```
|
|
718
|
+
|
|
719
|
+
### `link` — clickable text
|
|
720
|
+
|
|
721
|
+
```tsx
|
|
722
|
+
column({
|
|
723
|
+
label: "Order",
|
|
724
|
+
accessor: (row) => row.reference,
|
|
725
|
+
type: "link",
|
|
726
|
+
typeOptions: { href: (row) => `/orders/${row.id}` },
|
|
727
|
+
});
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
- `href` is **required** on `link` columns — it's enforced by the type.
|
|
731
|
+
- Returning `null` / `undefined` from `href` renders the cell value as plain text (useful for "no detail page yet" rows).
|
|
732
|
+
- Uses the app-shell `<Link>` (react-router) so SPA navigation is preserved. For external URLs, fall back to `render` with a plain `<a>`.
|
|
733
|
+
|
|
734
|
+
### Overriding a built-in renderer
|
|
735
|
+
|
|
736
|
+
`render` always wins over the built-in renderer, so the escape hatch stays open per column:
|
|
737
|
+
|
|
738
|
+
```tsx
|
|
739
|
+
column({
|
|
740
|
+
label: "Status",
|
|
741
|
+
accessor: (row) => row.status,
|
|
742
|
+
type: "badge",
|
|
743
|
+
typeOptions: { badgeVariantMap: { active: "success" } },
|
|
744
|
+
// Custom render with an icon — type/typeOptions are still required for
|
|
745
|
+
// sort/filter scaffolding but are bypassed for rendering.
|
|
746
|
+
render: (row) => (
|
|
747
|
+
<span className="flex items-center gap-1">
|
|
748
|
+
<CircleCheck className="size-3" />
|
|
749
|
+
{row.status}
|
|
750
|
+
</span>
|
|
751
|
+
),
|
|
752
|
+
});
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
### Combining `type` with `inferColumns`
|
|
756
|
+
|
|
757
|
+
`inferColumns` (from `@tailor-platform/sdk-plugin-app-shell`) derives `label`, `sort`, `filter`, and `id` from TailorDB metadata. You can layer a `type` on top to get a built-in renderer without losing the inferred sort/filter config:
|
|
758
|
+
|
|
759
|
+
```tsx
|
|
760
|
+
const infer = inferColumns(tableMetadata.order);
|
|
761
|
+
|
|
762
|
+
const columns = [
|
|
763
|
+
// Inferred column — displays row[id] as plain text
|
|
764
|
+
column(infer("reference")),
|
|
765
|
+
|
|
766
|
+
// Inferred datetime column, swapped to a `date` cell with long format
|
|
767
|
+
column({
|
|
768
|
+
...infer("placedAt"),
|
|
769
|
+
type: "date",
|
|
770
|
+
typeOptions: { dateFormat: "long" },
|
|
771
|
+
accessor: (row) => row.placedAt,
|
|
772
|
+
}),
|
|
773
|
+
|
|
774
|
+
// Inferred enum column, rendered as a badge
|
|
775
|
+
column({
|
|
776
|
+
...infer("status"),
|
|
777
|
+
type: "badge",
|
|
778
|
+
accessor: (row) => row.status,
|
|
779
|
+
typeOptions: {
|
|
780
|
+
badgeVariantMap: { active: "success", draft: "neutral" },
|
|
781
|
+
},
|
|
782
|
+
}),
|
|
783
|
+
];
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
When you spread `...infer("field")`, add `accessor` when you want a typed renderer to read a specific value — built-in renderers read from `accessor` (or `row[id]`).
|
|
787
|
+
|
|
788
|
+
## `FilterConfig`
|
|
789
|
+
|
|
790
|
+
The `filter` property on a column accepts the same base shape as `FilterConfig`, plus a DataTable-only `operators` allowlist. When set, the column becomes filterable in `DataTable.Filters` — available in the **Add filter** panel, and rendered as a segmented chip once active.
|
|
791
|
+
|
|
792
|
+
| Property | Type | Description |
|
|
793
|
+
| ----------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
|
|
794
|
+
| `field` | `string` | API field name used in the generated query input. |
|
|
795
|
+
| `type` | `FilterType` | Filter editor type (see table below). |
|
|
796
|
+
| `options` | `SelectOption[]` | Required when `type` is `"enum"`. List of selectable values. |
|
|
797
|
+
| `operators` | `readonly [FilterOperator, ...FilterOperator[]]` | Optional non-empty DataTable UI allowlist. Order controls menu order and, when provided, the default operator. |
|
|
798
|
+
|
|
799
|
+
`operators` only narrows what the built-in DataTable filter UI shows. Programmatic `CollectionControl.addFilter(...)`, URL state, and saved/persisted filters still use the broader backend operator set. When `operators` is omitted, the built-in UI keeps the normal default for that field type (for example, string fields still default to `contains`).
|
|
800
|
+
|
|
801
|
+
### Adding and editing filters
|
|
802
|
+
|
|
803
|
+
`DataTable.Filters` renders active filters as segmented chips followed by an **Add filter** button:
|
|
804
|
+
|
|
805
|
+
- **Add filter panel** — a single popover laid out in up to three columns: **field ▸ condition ▸ value**. The condition column appears for fields with more than one operator (number, date/time, string); single-operator fields (enum, uuid) go straight to the value. Enter a value and click **Apply**; the panel stays open so several filters can be added in a row.
|
|
806
|
+
- **Segmented chip** — each active filter shows `field │ operator │ value │ ✕`. The operator segment opens a searchable dropdown to change the condition; the value segment reopens the type-specific editor; `✕` removes the filter. Multi-select enum values are summarized as "N items" (e.g. "2 statuses").
|
|
807
|
+
|
|
808
|
+
### Filter Types and Operators
|
|
809
|
+
|
|
810
|
+
| Type | Input editor | Supported operators |
|
|
811
|
+
| ---------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
812
|
+
| `string` | Text | `eq`, `ne`, `contains`, `notContains`, `hasPrefix`, `hasSuffix`, `notHasPrefix`, `notHasSuffix`, `in`, `nin` |
|
|
813
|
+
| `number` | Number | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` |
|
|
814
|
+
| `decimal` | Decimal text (string value) | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` |
|
|
815
|
+
| `datetime` | Calendar / DatePicker + Time | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` |
|
|
816
|
+
| `date` | **Calendar / DatePicker** | `eq` (_exact date_), `gte` (_after_), `lte` (_before_), **`between`** |
|
|
817
|
+
| `time` | Time | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` |
|
|
818
|
+
| `enum` | Dropdown | `eq`, `ne`, `in`, `nin` |
|
|
819
|
+
| `boolean` | Toggle | `eq`, `ne` |
|
|
820
|
+
| `uuid` | Text | `eq`, `ne`, `in`, `nin` |
|
|
821
|
+
|
|
822
|
+
When the `between` operator is selected on a `number`, `decimal`, `datetime`, `date`, or `time` column, the value editor renders a range input with **min**/**max** (or **From**/**To** for dates) bounds.
|
|
823
|
+
|
|
824
|
+
### Decimal Filters
|
|
825
|
+
|
|
826
|
+
TailorDB `db.decimal()` fields are generated as metadata `type: "decimal"`. They support numeric sort and filter operators, but values remain **strings**, matching the GraphQL `Decimal` scalar without JavaScript number rounding. Scalar filters, `in`/`nin` arrays, and `between` bounds all use strings in `variables.query`.
|
|
827
|
+
|
|
828
|
+
The editor accepts signed decimal and scientific notation (for example, `"9007199254740993.123456789"` or `"1.23e-10"`) and checks range ordering without rounding. Sorting is delegated to the backend. As with `number`, `in`/`nin` are available programmatically, not in the built-in editor.
|
|
829
|
+
|
|
830
|
+
Regenerate metadata with the updated SDK plugin to replace older decimal fields marked as `"number"`. Decimal metadata does not select a numeric cell renderer; the default text rendering preserves the original value.
|
|
831
|
+
|
|
832
|
+
### Datetime Filters and Business Timezones
|
|
833
|
+
|
|
834
|
+
> **Important:** Set [`AppShell.timeZone`](./app-shell.md#timezone) explicitly when datetime filters must follow a shared business timezone. Without it, the filter editors use each user's local timezone. Users in different timezones can enter the same date and time but search against different instants, causing expected records to be missed. Setting `locale` alone does **not** set the timezone.
|
|
835
|
+
|
|
836
|
+
`locale` and `timeZone` are independent:
|
|
837
|
+
|
|
838
|
+
| Setting | Controls | Example |
|
|
839
|
+
| ---------- | ------------------------------------------------------------------------------------ | -------------- |
|
|
840
|
+
| `locale` | UI language and locale-specific date/time formatting | `"ja-JP"` |
|
|
841
|
+
| `timeZone` | The timezone used to interpret the date and time selected in datetime filter editors | `"Asia/Tokyo"` |
|
|
842
|
+
|
|
843
|
+
For example, a Japanese-language application can use a US business timezone. Choose the timezone that defines the business's date boundaries, not one inferred from the UI language or the user's location:
|
|
844
|
+
|
|
845
|
+
```tsx
|
|
846
|
+
import { AppShell } from "@tailor-platform/app-shell";
|
|
847
|
+
|
|
848
|
+
<AppShell modules={modules} locale="ja-JP" timeZone="America/New_York">
|
|
849
|
+
{/* Existing application content */}
|
|
850
|
+
</AppShell>;
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
Use an IANA timezone such as `"Asia/Tokyo"` or `"America/New_York"`, rather than a fixed UTC offset, so daylight-saving changes are accounted for. If per-user local-time filtering is intentional, leave `timeZone` unset; otherwise, configure the shared business timezone even when every user has the same `locale`.
|
|
854
|
+
|
|
855
|
+
#### Local input, RFC 3339 output
|
|
856
|
+
|
|
857
|
+
The datetime editor shows the date and time in the configured timezone, but sends an **instant** — a specific point in time — as an RFC 3339 UTC string. For example, the same input `2026-10-09 00:00` produces:
|
|
858
|
+
|
|
859
|
+
| Timezone used by the editor | Serialized filter value |
|
|
860
|
+
| --------------------------- | -------------------------- |
|
|
861
|
+
| `Asia/Tokyo` | `2026-10-08T15:00:00.000Z` |
|
|
862
|
+
| `America/New_York` | `2026-10-09T04:00:00.000Z` |
|
|
863
|
+
|
|
864
|
+
Applying or updating a datetime filter through `DataTable.Filters` passes these strings to `CollectionControl.addFilter`, so consumers reading `control.filters` receive UTC instants rather than timezone-less local datetime strings. A `between` filter serializes both `min` and `max` as instants. Existing RFC 3339 values are displayed in the editor's timezone without changing the instant they represent. `date` and `time` filters retain their `YYYY-MM-DD` and `HH:mm` formats.
|
|
865
|
+
|
|
866
|
+
A datetime `eq` filter matches one instant, not every record on a calendar day. To search a whole business day on a datetime field, use the start of that day as an inclusive lower bound and the start of the next day as an exclusive upper bound in the backend query. Calculate both boundaries in the business timezone; do not assume every day is exactly 24 hours.
|
|
867
|
+
|
|
868
|
+
When `tableMetadata` is supplied to `useCollectionVariables` or `useURLCollectionVariables`, datetime filters are normalized before the first query and before `addFilter` or `setFilters` updates the state. This also covers filters restored from URLs or saved views. Timezone-less legacy datetime strings are interpreted in the AppShell timezone; values with `Z` or an explicit offset retain their original instant. Single values, `in` / `nin` arrays, and both bounds of `between` filters are normalized. Invalid datetime values throw a `TypeError` rather than silently removing a search constraint; invalid updates leave the previous filter state unchanged. Datetime strings must use the ISO date-and-time form with `T`; date-only strings and browser-specific date formats are not accepted for datetime fields. If migrating legacy values, first establish which timezone they originally represented.
|
|
869
|
+
|
|
870
|
+
Without `tableMetadata`, the collection hook cannot distinguish datetime fields from ordinary strings and leaves filter values unchanged. Supply RFC 3339 datetime values with an explicit timezone in programmatic or persisted filters on this path. Setting a DataTable column's `filter.type` configures the UI but does not supply metadata to the collection hook.
|
|
871
|
+
|
|
872
|
+
Built-in `type: "date"` cells display timestamps in the AppShell timezone, and the cell menu uses that same timezone when adding a temporal filter. Date-only `YYYY-MM-DD` cell values retain their calendar date instead of shifting to the previous or next day. The setting does not change the browser's default timezone: when using `Intl.DateTimeFormat` in a custom cell renderer, pass the same timezone explicitly if its output must agree with the filters.
|
|
873
|
+
|
|
874
|
+
#### Calendar dates are not instants
|
|
875
|
+
|
|
876
|
+
Use the `date` field/filter type for values whose meaning is a calendar date, such as a purchase-order date or an accounting date. `2026-10-09` should remain that date regardless of where the user is located. Use `datetime` for events that occurred at a particular instant, such as a record's creation time.
|
|
877
|
+
|
|
878
|
+
If an existing backend stores document dates as datetime instants, a shared business timezone can make filter boundaries consistent, but it does not fix that data model. Changing only the column's filter type to `date` does not migrate the stored values or change the backend's query contract. Align the backend field type, data, and filters when moving to date-only semantics.
|
|
879
|
+
|
|
880
|
+
### Date Filters
|
|
881
|
+
|
|
882
|
+
`date` columns render app-shell date components as the value editor: single-value operators (`eq` / `gte` / `lte`) use an inline `Calendar`, and the `between` range uses two [`DatePicker`](./date-picker.md) **From** / **To** fields. (The range fields are a stopgap — they'll be replaced with a single range calendar once date-range support lands in the date-picker component.) `date` columns also present a friendlier, slimmer operator set:
|
|
883
|
+
|
|
884
|
+
| Operator | Label | Meaning |
|
|
885
|
+
| --------- | ------------ | -------------------------- |
|
|
886
|
+
| `eq` | _exact date_ | matches that calendar date |
|
|
887
|
+
| `gte` | _after_ | on or after (inclusive) |
|
|
888
|
+
| `lte` | _before_ | on or before (inclusive) |
|
|
889
|
+
| `between` | _between_ | inclusive min–max range |
|
|
890
|
+
|
|
891
|
+
`gt` / `lt` / `ne` are intentionally dropped — the inclusive _after_ / _before_ cover the intent. The filter chip shows the value as a locale-formatted date (e.g. `15 Jun 2026`), and the picker resolves its locale/timezone from the AppShell context. (Only `date` is remapped this way; `datetime` and `time` keep the full numeric operator set. Datetime editors pair a calendar/date picker with a time input; `time` uses a native time input.)
|
|
892
|
+
|
|
893
|
+
### String Filter Case Sensitivity
|
|
894
|
+
|
|
895
|
+
String filters are **case-insensitive by default** — they use the Tailor Platform `regex` operator with an `(?i)` prefix. The filter chip renders a **"Case sensitive"** checkbox that lets users opt into exact-case matching.
|
|
896
|
+
|
|
897
|
+
To control this behavior programmatically, pass `caseSensitive: true` to `CollectionControl.addFilter`:
|
|
898
|
+
|
|
899
|
+
```tsx
|
|
900
|
+
control.addFilter("title", "contains", "acme", { caseSensitive: true });
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
You can also set `caseSensitive` directly on a `Filter` object when using `params.initialFilters` in `useCollectionVariables`:
|
|
904
|
+
|
|
905
|
+
```tsx
|
|
906
|
+
const { variables, control } = useCollectionVariables({
|
|
907
|
+
params: {
|
|
908
|
+
initialFilters: [{ field: "title", operator: "contains", value: "acme", caseSensitive: true }],
|
|
909
|
+
},
|
|
910
|
+
});
|
|
911
|
+
```
|
|
912
|
+
|
|
913
|
+
When `caseSensitive` is omitted or `false`, the filter is case-insensitive. When `true`, the filter matches the exact case of the input.
|
|
914
|
+
|
|
915
|
+
## `RowAction`
|
|
916
|
+
|
|
917
|
+
A row action in the kebab-menu column. `RowAction<TRow>` extends the non-generic [`DataTableAction`](#datatableaction) (`id`, `label`, `icon`, `variant`) and adds row-based behavior.
|
|
918
|
+
|
|
919
|
+
| Property | Type | Description |
|
|
920
|
+
| ------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
921
|
+
| `canApply` | `(row: TRow) => boolean` | Return `false` to disable the action for a given row. |
|
|
922
|
+
| `isDisabled` | `(row: TRow) => boolean` | **Deprecated** — use `canApply`, which reads the other way round. Still honoured: the action is disabled if either one says so. |
|
|
923
|
+
| `onClick` | `(row: TRow) => void` | Called when the action is clicked. |
|
|
924
|
+
|
|
925
|
+
## `SelectionAction`
|
|
926
|
+
|
|
927
|
+
A non-generic bulk action for selected ids. Extends [`DataTableAction`](#datatableaction). See [Selection actions](#selection-actions).
|
|
928
|
+
|
|
929
|
+
| Property | Type | Description |
|
|
930
|
+
| --------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
931
|
+
| `onClick` | `(ids: string[], helpers: SelectionActionHelpers) => void \| Promise<unknown>` | Receives a copy of all selected ids at click time, including unloaded ids. Return a promise, or pass a later request to `helpers.run`, for pending-state tracking only. Selection is never cleared automatically. |
|
|
932
|
+
|
|
933
|
+
`SelectionActionHelpers` provides `clearSelection(): void` to explicitly clear every page and `run<T>(request: PromiseLike<T>): PromiseLike<T>` to track a later request without changing selection. Eligibility, fetching, confirmation, execution, and result-based selection updates belong to the consumer.
|
|
934
|
+
|
|
935
|
+
## `DataTableAction`
|
|
936
|
+
|
|
937
|
+
Non-generic presentation shared by `RowAction<TRow>` and `SelectionAction`. Share these fields, not eligibility rules: row actions can evaluate a concrete row, while selection actions operate on ids.
|
|
938
|
+
|
|
939
|
+
| Property | Type | Description |
|
|
940
|
+
| --------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
941
|
+
| `id` | `string` | Stable identifier for the action. |
|
|
942
|
+
| `label` | `string` | Display label — the menu item for a row action, the button (or **More actions** item) for a selection action. |
|
|
943
|
+
| `icon` | `ReactNode` | Optional icon shown beside the label. |
|
|
944
|
+
| `variant` | `"default" \| "destructive"` | Visual style. A destructive action still needs its own confirmation step. |
|
|
945
|
+
|
|
946
|
+
```tsx
|
|
947
|
+
const archive: DataTableAction = {
|
|
948
|
+
id: "archive",
|
|
949
|
+
label: "Archive",
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
useDataTable<Order>({
|
|
953
|
+
columns,
|
|
954
|
+
data,
|
|
955
|
+
rowActions: [
|
|
956
|
+
{
|
|
957
|
+
...archive,
|
|
958
|
+
canApply: (order) => order.status !== "Archived",
|
|
959
|
+
onClick: (order) => archiveOrders([order.id]),
|
|
960
|
+
},
|
|
961
|
+
],
|
|
962
|
+
rowSelection: {
|
|
963
|
+
actions: [{ ...archive, onClick: (ids) => archiveOrders(ids) }],
|
|
964
|
+
},
|
|
965
|
+
});
|
|
966
|
+
```
|
|
967
|
+
|
|
968
|
+
## `createColumnHelper`
|
|
969
|
+
|
|
970
|
+
Factory that captures the row type once and returns `column` and `inferColumns` with `TRow` already bound. Prefer this over the standalone `column()` function to avoid repeating the generic parameter.
|
|
971
|
+
|
|
972
|
+
```tsx
|
|
973
|
+
const { column, inferColumns } = createColumnHelper<Order>();
|
|
974
|
+
```
|
|
975
|
+
|
|
976
|
+
### `column(options)`
|
|
977
|
+
|
|
978
|
+
Defines a column with an explicit render function.
|
|
979
|
+
|
|
980
|
+
```tsx
|
|
981
|
+
column({ label: "Name", render: (row) => row.name });
|
|
982
|
+
column({ label: "Actions", render: (row) => <button>Edit {row.name}</button> });
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
### `inferColumns(tableMetadata)`
|
|
986
|
+
|
|
987
|
+
Binds table metadata and returns a per-field column factory. The factory derives `label`, `sort`, `filter` config, and `id` automatically from the field's metadata. `id` is always pinned to the metadata field name — this stabilizes the React key / column-visibility identifier and enables the `truncate` tooltip without an explicit `accessor`. Requires metadata generated by `@tailor-platform/sdk-plugin-app-shell`.
|
|
988
|
+
|
|
989
|
+
```tsx
|
|
990
|
+
const infer = inferColumns(tableMetadata.order);
|
|
991
|
+
|
|
992
|
+
const columns = [
|
|
993
|
+
column(infer("title")),
|
|
994
|
+
column(infer("status")),
|
|
995
|
+
column({ ...infer("createdAt"), render: (row) => formatDate(row.createdAt) }),
|
|
996
|
+
];
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
The factory accepts an optional second argument to override per-column defaults:
|
|
1000
|
+
|
|
1001
|
+
| Option | Type | Default | Description |
|
|
1002
|
+
| -------- | --------------------------------------------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1003
|
+
| `label` | `string` | Field `description` or `name` from metadata | Override the column header text. |
|
|
1004
|
+
| `width` | `number` | — | Fixed column width in pixels. |
|
|
1005
|
+
| `sort` | `boolean` | `true` | Set to `false` to suppress the auto-generated sort config. |
|
|
1006
|
+
| `filter` | `boolean \| { operators?: readonly [FilterOperator, ...FilterOperator[]] }` | `true` | Set to `false` to suppress the auto-generated filter config, or pass a non-empty `operators` allowlist to narrow the built-in filter UI. |
|
|
1007
|
+
|
|
1008
|
+
Like `column.filter`, `operators` only affects the built-in DataTable UI. Order controls menu order and, when provided, the default operator. Programmatic `CollectionControl.addFilter(...)`, URL state, and saved/persisted filters still accept the broader backend operator set.
|
|
1009
|
+
|
|
1010
|
+
## `useCollectionVariables`
|
|
1011
|
+
|
|
1012
|
+
Manages collection query state (filters, sort, pagination) and derives `variables` for GraphQL queries.
|
|
1013
|
+
|
|
1014
|
+
```tsx
|
|
1015
|
+
const { variables, control } = useCollectionVariables({
|
|
1016
|
+
params: { pageSize: 20 },
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
// variables.pagination → { first, after? } or { last, before? }
|
|
1020
|
+
// variables.query → filter input object or undefined
|
|
1021
|
+
// variables.order → sort input array or undefined
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
### Options
|
|
1025
|
+
|
|
1026
|
+
| Option | Type | Description |
|
|
1027
|
+
| ----------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
1028
|
+
| `params.pageSize` | `number` | Initial page size. Default: `20`. |
|
|
1029
|
+
| `params.initialFilters` | `Filter[]` | Filters applied on first render. |
|
|
1030
|
+
| `params.initialSort` | `SortState[]` | Sort applied on first render. |
|
|
1031
|
+
| `tableMetadata` | `TableMetadata` | Generated table metadata. Enables typed variables and datetime normalization (see [Datetime filters](#datetime-filters-and-business-timezones)). |
|
|
1032
|
+
| `onParamsChange` | `(params: CollectionParams) => void` | Called after each filter, sort, or page-size change with the current params. |
|
|
1033
|
+
|
|
1034
|
+
### Return Value
|
|
1035
|
+
|
|
1036
|
+
| Property | Type | Description |
|
|
1037
|
+
| ----------- | --------------------- | -------------------------------------------------------------- |
|
|
1038
|
+
| `variables` | `CollectionVariables` | Derived `query`, `order`, and `pagination` sub-properties. |
|
|
1039
|
+
| `control` | `CollectionControl` | State and methods for filter, sort, and pagination management. |
|
|
1040
|
+
|
|
1041
|
+
`useCollectionVariables` is decoupled from `DataTable` by design — the hook owns only query state and exposes plain variables. Any collection-based view (Kanban, Gantt, custom components) can use the same hook without modification.
|
|
1042
|
+
|
|
1043
|
+
### Typed query variables
|
|
1044
|
+
|
|
1045
|
+
When using typed GraphQL documents (`TypedDocumentNode`), pass `tableMetadata` to `useCollectionVariables`. This narrows `variables.query` and `variables.order` from `unknown` to the precise types expected by the generated document.
|
|
1046
|
+
|
|
1047
|
+
```tsx
|
|
1048
|
+
import { tableMetadata } from "@/generated/app-shell-datatable.generated";
|
|
1049
|
+
|
|
1050
|
+
const { variables, control } = useCollectionVariables({
|
|
1051
|
+
tableMetadata: tableMetadata.order,
|
|
1052
|
+
params: { pageSize: 20 },
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
// variables.query is now BuildQueryVariables<typeof tableMetadata.order>
|
|
1056
|
+
// variables.order is now { field: OrderableFieldName; direction: "Asc" | "Desc" }[]
|
|
1057
|
+
const [result] = useQuery({
|
|
1058
|
+
query: LIST_ORDERS, // TypedDocumentNode — variables are fully type-checked
|
|
1059
|
+
variables: {
|
|
1060
|
+
...variables.pagination,
|
|
1061
|
+
query: variables.query,
|
|
1062
|
+
order: variables.order,
|
|
1063
|
+
},
|
|
1064
|
+
});
|
|
1065
|
+
```
|
|
1066
|
+
|
|
1067
|
+
## `useURLCollectionVariables`
|
|
1068
|
+
|
|
1069
|
+
Wraps `useCollectionVariables` with automatic URL persistence. It seeds filter, sort, and page-size state from the current URL search params on mount and writes changes back to the URL as the user interacts with the table — using `replace` navigation so individual interactions don't push new history entries.
|
|
1070
|
+
|
|
1071
|
+
Use this instead of `useCollectionVariables` when you want filters, sort, and pagination to survive page refreshes and be shareable via URL.
|
|
1072
|
+
|
|
1073
|
+
```tsx
|
|
1074
|
+
import { useURLCollectionVariables } from "@tailor-platform/app-shell";
|
|
1075
|
+
|
|
1076
|
+
const { variables, control } = useURLCollectionVariables({
|
|
1077
|
+
tableMetadata,
|
|
1078
|
+
params: { pageSize: 20 },
|
|
1079
|
+
});
|
|
1080
|
+
```
|
|
1081
|
+
|
|
1082
|
+
The return value is identical to `useCollectionVariables` — `variables` and `control`.
|
|
1083
|
+
|
|
1084
|
+
### Options
|
|
1085
|
+
|
|
1086
|
+
All options accepted by `useCollectionVariables` are accepted here too. `tableMetadata` is optional but recommended for typed variables and correct URL round-tripping of typed field values (numbers and booleans are preserved correctly, and datetime filters are normalized to RFC 3339 instants before querying).
|
|
1087
|
+
|
|
1088
|
+
### URL format
|
|
1089
|
+
|
|
1090
|
+
| State | URL key | Example |
|
|
1091
|
+
| ---------- | ---------------------- | --------------------- |
|
|
1092
|
+
| `pageSize` | `p` | `?p=20` |
|
|
1093
|
+
| Sort | `s` | `?s=createdAt:desc` |
|
|
1094
|
+
| Filter | `f.<field>:<operator>` | `?f.status:eq=ACTIVE` |
|
|
1095
|
+
|
|
1096
|
+
### Custom search-params binding: `withURLCollectionState`
|
|
1097
|
+
|
|
1098
|
+
If you need URL persistence but cannot use react-router's `useSearchParams` (e.g. a different router or test environment), use the pure `withURLCollectionState` decorator to compose URL state with `useCollectionVariables` directly:
|
|
1099
|
+
|
|
1100
|
+
```tsx
|
|
1101
|
+
import { withURLCollectionState, useCollectionVariables } from "@tailor-platform/app-shell";
|
|
1102
|
+
|
|
1103
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
1104
|
+
|
|
1105
|
+
const { variables, control } = useCollectionVariables(
|
|
1106
|
+
withURLCollectionState({ tableMetadata, params: { pageSize: 20 } }, [
|
|
1107
|
+
searchParams,
|
|
1108
|
+
setSearchParams,
|
|
1109
|
+
]),
|
|
1110
|
+
);
|
|
1111
|
+
```
|
|
1112
|
+
|
|
1113
|
+
`withURLCollectionState` returns augmented `useCollectionVariables` options — it does not call the hook itself. The `[searchParams, setSearchParams]` tuple must match the `URLSearchParams` + setter shape that `useSearchParams()` returns.
|
|
1114
|
+
|
|
1115
|
+
## `useDataTableContext`
|
|
1116
|
+
|
|
1117
|
+
Accesses the full DataTable state from any component rendered inside `DataTable.Root`. Use this to build custom sub-components when the built-in ones don't fit.
|
|
1118
|
+
|
|
1119
|
+
```tsx
|
|
1120
|
+
import { useDataTableContext } from "@tailor-platform/app-shell";
|
|
1121
|
+
|
|
1122
|
+
function MyCustomPagination() {
|
|
1123
|
+
const { pageInfo, goToNextPage, goToPrevPage, hasNextPage, hasPrevPage } = useDataTableContext();
|
|
1124
|
+
// ...
|
|
1125
|
+
}
|
|
1126
|
+
```
|
|
1127
|
+
|
|
1128
|
+
The selection is here too — `selectedIds`, `selectAllRows` / `deselectAllRows` for the current page, and `clearSelection` — for the rare case the built-in [selection actions](#selection-actions) bar doesn't fit. Selection exposes IDs only; consumers fetch any row objects they need.
|
|
1129
|
+
|
|
1130
|
+
## SDK Plugin (`@tailor-platform/sdk-plugin-app-shell`)
|
|
1131
|
+
|
|
1132
|
+
The SDK plugin generates `tableMetadata` from TailorDB table definitions at generate time. This metadata bridges your schema to the DataTable — it specifies how each field should be rendered and filtered (e.g. date pickers for datetime fields, dropdown for enum fields).
|
|
1133
|
+
|
|
1134
|
+
Register the plugin in `tailor.config.ts` and run `tailor generate`:
|
|
1135
|
+
|
|
1136
|
+
```ts
|
|
1137
|
+
import { definePlugins } from "@tailor-platform/sdk";
|
|
1138
|
+
import { appShellPlugin } from "@tailor-platform/sdk-plugin-app-shell";
|
|
1139
|
+
|
|
1140
|
+
export const plugins = definePlugins(
|
|
1141
|
+
appShellPlugin({
|
|
1142
|
+
dataTable: {
|
|
1143
|
+
metadataOutputPath: "src/generated/app-shell-datatable.generated.ts",
|
|
1144
|
+
},
|
|
1145
|
+
}),
|
|
1146
|
+
);
|
|
1147
|
+
```
|
|
1148
|
+
|
|
1149
|
+
The generated file exports `tableMetadata`, `tableNames`, and `TableName`. Pass `tableMetadata` to `inferColumns` to get type-safe column definitions with filter editors automatically configured:
|
|
1150
|
+
|
|
1151
|
+
```ts
|
|
1152
|
+
import { tableMetadata } from "@/generated/app-shell-datatable.generated";
|
|
1153
|
+
import { createColumnHelper } from "@tailor-platform/app-shell";
|
|
1154
|
+
|
|
1155
|
+
const { column, inferColumns } = createColumnHelper<Order>();
|
|
1156
|
+
const infer = inferColumns(tableMetadata.order);
|
|
1157
|
+
|
|
1158
|
+
const columns = [
|
|
1159
|
+
column(infer("title")), // string → text filter
|
|
1160
|
+
column(infer("status")), // enum → dropdown filter with generated values
|
|
1161
|
+
column(infer("createdAt")), // datetime → date picker filter
|
|
1162
|
+
];
|
|
1163
|
+
```
|
|
1164
|
+
|
|
1165
|
+
## Related
|
|
1166
|
+
|
|
1167
|
+
- [CsvImporter](./csv-importer.md) — Guided CSV import flow
|
|
1168
|
+
- [Table](./table.md) — Low-level table primitives used internally by DataTable
|