@tailor-platform/app-shell 1.16.0 → 1.18.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.
Files changed (45) hide show
  1. package/dist/app-shell-core.css +1 -1
  2. package/dist/app-shell.d.ts +762 -51
  3. package/dist/app-shell.js +7642 -5636
  4. package/dist/{appshell-B0wh5Lq0.js → appshell-Y1AYCuVJ.js} +323 -304
  5. package/dist/semantic-contrast.test.ts +227 -0
  6. package/dist/style-entry.d.ts +61 -26
  7. package/dist/testing.d.ts +66 -29
  8. package/dist/testing.js +1 -1
  9. package/dist/theme-bridge.test.ts +124 -1
  10. package/dist/theme.bridge.css +178 -0
  11. package/dist/theme.css +8 -2
  12. package/dist/themes/_template.css +8 -3
  13. package/dist/themes/bloom.css +0 -8
  14. package/dist/themes/cream.css +0 -8
  15. package/dist/themes/default.css +458 -103
  16. package/dist/vite-plugin.d.ts +61 -26
  17. package/package.json +2 -2
  18. package/skills/app-shell-patterns/SKILL.md +48 -37
  19. package/skills/app-shell-patterns/references/api/define-i18n-labels.md +123 -91
  20. package/skills/app-shell-patterns/references/api/define-module.md +4 -6
  21. package/skills/app-shell-patterns/references/api/define-resource.md +2 -2
  22. package/skills/app-shell-patterns/references/api/use-app-shell-config.md +53 -47
  23. package/skills/app-shell-patterns/references/api/use-app-shell-data.md +30 -26
  24. package/skills/app-shell-patterns/references/api/use-app-shell.md +37 -30
  25. package/skills/app-shell-patterns/references/api/use-toast.md +28 -31
  26. package/skills/app-shell-patterns/references/components/app-shell.md +4 -4
  27. package/skills/app-shell-patterns/references/components/card.md +79 -7
  28. package/skills/app-shell-patterns/references/components/data-table.md +599 -77
  29. package/skills/app-shell-patterns/references/components/date-picker.md +32 -21
  30. package/skills/app-shell-patterns/references/components/filter-rail.md +165 -0
  31. package/skills/app-shell-patterns/references/components/sidebar-group.md +12 -6
  32. package/skills/app-shell-patterns/references/components/sidebar-section.md +114 -0
  33. package/skills/app-shell-patterns/references/components/text.md +198 -0
  34. package/skills/app-shell-patterns/references/concepts/custom-components.md +1 -1
  35. package/skills/app-shell-patterns/references/concepts/file-based-routing.md +7 -1
  36. package/skills/app-shell-patterns/references/concepts/graphql.md +20 -14
  37. package/skills/app-shell-patterns/references/concepts/routing-navigation.md +41 -3
  38. package/skills/app-shell-patterns/references/concepts/sidebar-navigation.md +32 -0
  39. package/skills/app-shell-patterns/references/concepts/styling-theming.md +278 -65
  40. package/skills/app-shell-patterns/references/migrations.md +43 -1
  41. package/skills/app-shell-patterns/references/pages/document-detail.md +6 -6
  42. package/skills/app-shell-patterns/references/patterns/form-modal.md +26 -20
  43. package/skills/app-shell-patterns/references/patterns/i18n-labels.md +139 -0
  44. package/skills/app-shell-patterns/references/patterns/interaction-multi-select.md +129 -118
  45. package/skills/app-shell-patterns/references/patterns/list-dense-scan.md +5 -4
@@ -22,11 +22,20 @@ import {
22
22
  type DataTableData,
23
23
  type DataTableRootProps,
24
24
  type DataTablePaginationProps,
25
+ type DataTableAction,
25
26
  type RowAction,
27
+ type RowSelectionOptions,
28
+ type SelectionAction,
26
29
  type UseDataTableOptions,
27
30
  type UseDataTableReturn,
28
31
  type MetadataFieldOptions,
29
32
  type DataTableContextValue,
33
+ type TextCellEditOptions,
34
+ type NumberCellEditOptions,
35
+ type BadgeCellEditOptions,
36
+ type DateCellEditOptions,
37
+ type CellEditState,
38
+ type DateTimeDraft,
30
39
  } from "@tailor-platform/app-shell";
31
40
  ```
32
41
 
@@ -144,15 +153,15 @@ function JournalsPage() {
144
153
 
145
154
  `DataTable` is a namespace object. All sub-components read state from `DataTable.Root` via context.
146
155
 
147
- | Sub-component | Description |
148
- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
149
- | `DataTable.Root` | Context provider. Wraps all other sub-components. Required. |
150
- | `DataTable.Table` | Renders the `<table>` with headers and body. Required. |
151
- | `DataTable.Toolbar` | DataTable-specific toolbar container. Optional; pass `columnSettings` for the built-in Columns control. |
152
- | `DataTable.ColumnSettings` | The placeable Columns control (show/hide + reorder + pin). Use it in a generic [`Toolbar`](./toolbar.md) for custom layouts. |
153
- | `DataTable.Filters` | Add-filter panel + active filter chips, auto-generated from column filter configs. Requires `control` from `useCollectionVariables`. |
154
- | `DataTable.Footer` | Footer container for pagination and other footer content. Optional. |
155
- | `DataTable.Pagination` | Pre-built pagination controls with optional row count and selection info. Requires `control` from `useCollectionVariables`. Place inside `DataTable.Footer`. |
156
+ | Sub-component | Description |
157
+ | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
158
+ | `DataTable.Root` | Context provider. Wraps all other sub-components. Required. |
159
+ | `DataTable.Table` | Renders the `<table>` with headers and body. Required. |
160
+ | `DataTable.Toolbar` | DataTable-specific toolbar container. Optional; pass `columnSettings` for the built-in Columns control. |
161
+ | `DataTable.ColumnSettings` | The placeable Columns control (show/hide + reorder + pin). Use it in a generic [`Toolbar`](./toolbar.md) for custom layouts. |
162
+ | `DataTable.Filters` | Add-filter panel + active filter chips, auto-generated from column filter configs. Requires `control` from `useCollectionVariables`. |
163
+ | `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). |
164
+ | `DataTable.Pagination` | Pre-built pagination controls with optional row count and selection info. Requires `control` from `useCollectionVariables`. Place inside `DataTable.Footer`. |
156
165
 
157
166
  ### `DataTable.Root` Props
158
167
 
@@ -220,7 +229,7 @@ By default `DataTable.Filters` renders the active filter chips plus the **Add fi
220
229
  | Rows selected and `total` is not provided | `Y row(s) selected` |
221
230
  | No selection enabled and no `total` | _(nothing displayed)_ |
222
231
 
223
- Row selection is enabled by providing `onSelectionChange` to `useDataTable`. The `total` value comes from `DataTableData.total`.
232
+ 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.
224
233
 
225
234
  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.
226
235
 
@@ -368,9 +377,418 @@ The trigger is a native `<button>`, so Enter/Space activation and the focus ring
368
377
 
369
378
  **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.
370
379
 
380
+ ## Inline editing
381
+
382
+ Give a typed column an `edit` config to use its built-in editor. This standardizes the editing UI — draft input, IME, keyboard movement, and change notifications — not business validation or persistence. Columns without `edit` keep their existing behavior.
383
+
384
+ | Column type | Editor | `onValueChange` draft | `onValueCommitted` output |
385
+ | ------------------------------------ | ----------------------------------------- | ----------------------------------------------------------------- | -------------------------------------- |
386
+ | `text` / `link` | Direct text input | Raw `string`, including `""` | `string \| null` |
387
+ | `number` / `money` | Direct numeric input | Raw `string`, including incomplete input such as `"-"` | JavaScript `number \| null` |
388
+ | `text` / `link` with `edit.options` | Searchable dropdown | Selected string `value`, or `null`; not search text or highlights | Selected string `value`, or `null` |
389
+ | `badge` | Searchable dropdown using the same badges | Selected string `value`, or `null`; not search text or highlights | Selected string `value`, or `null` |
390
+ | `date` | Calendar | `"YYYY-MM-DD" \| null` | `"YYYY-MM-DD" \| null` |
391
+ | `date` with `dateFormat: "datetime"` | Calendar and time field | Complete `DateTimeDraft` snapshot | UTC ISO timestamp, or `null` for Clear |
392
+
393
+ Callback types describe the **editor's output**, not the original row field's runtime type. For example, text/link/badge editors confirm strings or `null` even when their display accepts numbers or booleans. The editing option types, `CellEditState`, and `DateTimeDraft` are exported from the package root; parsing and navigation helpers remain internal.
394
+
395
+ For example, inside a receipt page, accept or reject each proposed quantity yourself. Patch the changed field with a functional state update rather than replacing a whole row captured before another edit:
396
+
397
+ ```tsx
398
+ const { column } = createColumnHelper<ReceiptLine>();
399
+ const [lines, setLines] = useState<ReceiptLine[]>(initialLines);
400
+ const toast = useToast();
401
+
402
+ const columns = [
403
+ column({ id: "sku", label: "SKU", type: "text" }),
404
+ column({
405
+ id: "received",
406
+ label: "Received",
407
+ type: "number",
408
+ typeOptions: { maxDecimals: 0 }, // Display precision, not an input rule.
409
+ edit: {
410
+ canEdit: (_row, { selected }) => selected,
411
+ onValueCommitted: (row, value) => {
412
+ if (value !== null && (!Number.isInteger(value) || value < 0 || value > row.ordered)) {
413
+ toast.error(`Enter a whole number from 0 to ${row.ordered}`);
414
+ return; // Do not adopt the value: the cell returns to data.
415
+ }
416
+ setLines((current) =>
417
+ current.map((line) => (line.id === row.id ? { ...line, received: value } : line)),
418
+ );
419
+ },
420
+ },
421
+ }),
422
+ ];
423
+
424
+ const table = useDataTable({ columns, data: { rows: lines }, rowSelection: {} });
425
+ ```
426
+
427
+ `ReceiptLine` and `initialLines` are the page's row type and initial data. `useState` comes from React; `useToast`, `createColumnHelper`, and `useDataTable` come from AppShell.
428
+
429
+ ### Editing options
430
+
431
+ | Option | Column types | Contract |
432
+ | ------------------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
433
+ | `onValueChange` | all typed columns | Optional: `(row, draft) => void`. Observes changed editor drafts, including restored drafts on cancellation. See the draft types above. Updating `data` here is outside the supported coordination contract and is the consumer's responsibility. Return values are ignored. |
434
+ | `onValueCommitted` | all typed columns | Required: `(row, value) => void`. Notifies a changed, confirmed value, compared with current consumer-provided `data`, not an edit-start snapshot. Empty or explicitly cleared cells confirm `null`; equal values do not notify. Confirmation does not imply successful business validation, adoption, or persistence. Return values are ignored. |
435
+ | `canEdit` | all typed columns | `(row, { selected }) => boolean`. Decides which rows can be edited, like `rowExpansion.canExpand` and an action's `canApply`. Default: rows with an `id`. `selected` is `false` when selection is off; provide `rowSelection: {}` to enable it. |
436
+ | `options` | `text`, `link`, `badge` | `readonly { value, label }[]`. Text/link columns become searchable dropdowns; values are strings and their labels are displayed, including in read-only cells. Badge choices default to `typeOptions.badgeLabelMap`, then the column's enum filter options. Without choices, a badge column stays read-only. The "None" choice confirms `null`. |
437
+
438
+ Rows need a string or number `id`; rows without one remain read-only and produce a one-time warning. Multi-value badge cells also remain read-only. Editable cells carry a subtle tint at rest, layered over the row's hover and selected states, and drop it while being edited. Dropdown and date cells also show their editor icon on hover or focus; typed-into cells show none. The whole cell is the click target. Editing a cell does not fire `onClickRow`.
439
+
440
+ ### Confirmation and keyboard behavior
441
+
442
+ - **Enter / Shift+Enter:** confirm and end editing, returning to normal cell display while keeping focus on the same logical cell. Enter or clicking that focused display cell starts editing again.
443
+ - **Tab / Shift+Tab:** confirm and move to the next / previous editable cell, skipping read-only cells. At the ends, focus leaves the table normally.
444
+ - **Esc:** restore the draft from the latest `data`, notifying `onValueChange` only if the draft changed, without `onValueCommitted`.
445
+ - **Dropdowns:** type to filter choices. Exact labels rank first, then prefix matches, then substring matches, with original order retained within each rank. "None" is excluded while a search query is present and available with an empty query. Tab focuses a closed dropdown without opening it; one Enter opens it without confirming. While open, Enter confirms the candidate and ends editing in the same cell; Tab confirms a selected or searched candidate and moves on. Tab without selecting or searching only moves focus. A keyboard-highlighted candidate takes precedence over ranked search matches; hovering does not count as an explicit selection. Clicking a choice confirms immediately. Only Enter, Tab, or clicking a choice confirms: outside click, blur, and Esc discard the search and restore the latest `data`, even for an exactly typed label or keyboard-highlighted candidate, without either callback. Search/highlight changes emit neither callback; confirming a different value emits `onValueChange` followed by `onValueCommitted`.
446
+ - **Dates:** selecting a day emits its draft and confirms immediately. Clear explicitly confirms `null`.
447
+ - **Date-times:** day and time are independent. Both must be valid to enable Done; an empty time does not become midnight. Done confirms the timestamp in the AppShell timezone. Outside closing or Esc cancels and restores the latest data; Clear explicitly confirms `null`.
448
+ - **Blur:** confirms a changed direct-input value. Enter during IME composition confirms the conversion, not the cell. Composition changes may notify raw drafts; normalization waits until cell confirmation.
449
+
450
+ A date-time draft preserves missing parts rather than inventing a timestamp:
451
+
452
+ ```ts
453
+ type DateTimeDraft = {
454
+ day: string | null; // "YYYY-MM-DD", or an unselected day.
455
+ time: string | null; // "HH:mm", or an empty time field.
456
+ };
457
+ ```
458
+
459
+ `onValueChange` receives the complete snapshot whenever either part changes, not just the changed part.
460
+
461
+ Numeric confirmation uses JavaScript `number`, **not arbitrary-precision Decimal**. Values outside the supported safe range are rejected, and high-precision decimal strings can lose precision when converted to numbers. Editing uses `.` as the decimal separator independently of display locale; `typeOptions.locale` does not select a locale-aware input parser. Full-width digits, valid comma thousands grouping, and currency symbols are normalized on confirmation; a decimal comma such as `1,5` is not accepted. The existing numeric renderer's `Number` conversion is unchanged.
462
+
463
+ Malformed numeric input is allowed while typing and can notify raw drafts. Enter/Tab cannot confirm it: they show basic parsing feedback and hold focus. Blur discards it, restores the latest `data`, and notifies the restored draft without a commit. Cosmetic changes such as `10` to `10.00` can notify draft changes but do not emit `onValueCommitted`. This is basic parsing, not a business-rule API; `typeOptions` controls display only and never limits the entered value.
464
+
465
+ ### Data ownership and limits
466
+
467
+ The consumer's `data` is the source of truth. Normally, observe drafts in `onValueChange` and synchronously adopt confirmed values in `onValueCommitted`, optionally after your own validation or normalization. If you reject a confirmed value by leaving `data` unchanged, the cell returns to that value and Tab navigation remains available: the table does not retain a business-invalid draft, add a cell error, or block Tab navigation for you.
468
+
469
+ External data updates do not overwrite an active draft or emit draft callbacks themselves. Confirmation and cancellation use the latest data. With no active draft, the display follows data normally. Unmount silently discards internal drafts without either callback; if an ordinary blur occurs before unmount, its normal behavior still applies.
470
+
471
+ There are no `edit.min`, `max`, `maxDecimals`, `required`, `validate`, or `schema` options. Dropdowns and calendars are not constrained by business rules. Required-field handling, error feedback, saving, optimistic updates, rollback, and save indicators belong to the screen. Callback return values are not awaited; handle rejected saves in your own code rather than returning a Promise for DataTable to track. There is no built-in unsaved-changes or leave-page guard.
472
+
473
+ Editing does not re-run sorting or filtering: rows stay in place until the consumer supplies changed query data. Update only the changed field when persisting, and keep local rows current so later edits, row actions, and bulk actions see the intended data. Conflict resolution, consumer-state rollback, draft persistence across unmounts, and navigation guards remain outside DataTable's responsibility.
474
+
475
+ For a different editor or validation experience, leave `edit` unset and use the existing cell `render` escape hatch. A `render` supplied alongside `edit` customizes the display, not the built-in editor. Multi-value badge editing, a custom-editor option within `edit`, multi-cell paste, fill-down, and undo history are not provided.
476
+
477
+ ### Consumer-owned saving patterns
478
+
479
+ DataTable has no save modes, shared saving hook, built-in Save controls, or validation framework. The following are page-level patterns. Both examples use `onValueCommitted`; neither treats observing `onValueChange` as adopting or saving a value.
480
+
481
+ Both examples use these imports:
482
+
483
+ ```tsx
484
+ import { useRef, useState } from "react";
485
+ import { Button, DataTable, createColumnHelper, useDataTable } from "@tailor-platform/app-shell";
486
+ ```
487
+
488
+ Their simulated persistence rejects quantity `13` so error handling can be tried without a backend. Replace it with your application's API.
489
+
490
+ #### Immediate saving
491
+
492
+ Validate the confirmed value, adopt it with a functional field update, then persist only that field. This example permits later edits while saves are pending. A per-row request revision prevents a stale failure from rolling back a newer edit; rollback also patches only this field, preserving changes to other fields. Feedback, saved-value tracking, and in-flight status are consumer-owned.
493
+
494
+ Revision checks protect local UI updates, not server write ordering or external conflicts. For a real API, serialize writes to the same field or use server-side version checks, and reconcile/refetch after uncertain outcomes. The [goods-receipt showcase](../../examples/vite-app/src/pages/showcase/data-table-lab/inline-editing-demo.tsx) demonstrates the same ownership boundary across editor types.
495
+
496
+ <example-preview name="immediate-saving"></example-preview>
497
+
498
+ ```tsx
499
+ function ImmediateSaving() {
500
+ type Line = { id: string; item: string; ordered: number; received: number | null };
501
+ const initialRows: Line[] = [
502
+ { id: "1", item: "Small carton", ordered: 24, received: 12 },
503
+ { id: "2", item: "Large carton", ordered: 36, received: null },
504
+ ];
505
+ const [rows, setRows] = useState(initialRows);
506
+ const [error, setError] = useState<string | null>(null);
507
+ const [savingCount, setSavingCount] = useState(0);
508
+ const saves = useRef(
509
+ new Map(
510
+ initialRows.map((row) => [
511
+ row.id,
512
+ {
513
+ revision: 0,
514
+ savedRevision: 0,
515
+ savedValue: row.received,
516
+ },
517
+ ]),
518
+ ),
519
+ );
520
+ const { column } = createColumnHelper<Line>();
521
+ const update = (id: string, received: number | null) =>
522
+ setRows((current) => current.map((row) => (row.id === id ? { ...row, received } : row)));
523
+
524
+ async function persistField(id: string, received: number | null) {
525
+ // Demo API: replace with a request that patches only this row's received field.
526
+ await new Promise((resolve) => setTimeout(resolve, 400));
527
+ if (received === 13) throw new Error(`Could not save row ${id}; quantity 13 was rejected`);
528
+ }
529
+
530
+ const table = useDataTable({
531
+ data: { rows },
532
+ columns: [
533
+ column({ id: "item", label: "Item", type: "text" }),
534
+ column({ id: "ordered", label: "Ordered", type: "number" }),
535
+ column({
536
+ id: "received",
537
+ label: "Received",
538
+ type: "number",
539
+ edit: {
540
+ onValueCommitted: (row, value) => {
541
+ if (value !== null && (!Number.isInteger(value) || value < 0 || value > row.ordered)) {
542
+ setError(`Enter a whole number from 0 to ${row.ordered}, or clear the cell`);
543
+ return;
544
+ }
545
+ const request = saves.current.get(row.id)!;
546
+ const revision = ++request.revision;
547
+ setError(null);
548
+ update(row.id, value);
549
+ setSavingCount((count) => count + 1);
550
+ void persistField(row.id, value)
551
+ .then(() => {
552
+ if (revision > request.savedRevision) {
553
+ request.savedRevision = revision;
554
+ request.savedValue = value;
555
+ }
556
+ })
557
+ .catch((cause: unknown) => {
558
+ if (revision !== request.revision) return;
559
+ update(row.id, request.savedValue);
560
+ setError(cause instanceof Error ? cause.message : "Save failed");
561
+ })
562
+ .finally(() => setSavingCount((count) => count - 1));
563
+ },
564
+ },
565
+ }),
566
+ ],
567
+ });
568
+
569
+ return (
570
+ <div className="space-y-3">
571
+ <p role="status">
572
+ {savingCount ? `${savingCount} field save(s) pending` : "No saves pending"}
573
+ </p>
574
+ {error && <p role="alert">{error}</p>}
575
+ <DataTable.Root value={table}>
576
+ <DataTable.Table />
577
+ </DataTable.Root>
578
+ </div>
579
+ );
580
+ }
581
+ ```
582
+
583
+ #### Collect then submit
584
+
585
+ Confirmed values update local draft rows. Save validates those rows and persists the batch; Cancel restores the last saved rows. The page disables editing while its batch is saving to avoid replacing a later local edit with an older save result.
586
+
587
+ This batch collects **confirmed/adopted row values**, not unfinished input inside editors. Save does not automatically gather or validate active editor drafts. An ordinary blur may confirm a valid direct input before a button click; malformed numeric blur discards that input, and clicking outside a dropdown or date-time picker cancels its unfinished edit. Tell users to confirm edits first; if your workflow needs to submit unfinished inputs as one coordinated form, use the `render` escape hatch or a dedicated form instead.
588
+
589
+ <example-preview name="collect-then-submit"></example-preview>
590
+
591
+ ```tsx
592
+ function CollectThenSubmit() {
593
+ type Line = { id: string; item: string; ordered: number; received: number | null };
594
+ const initialRows: Line[] = [
595
+ { id: "1", item: "Small carton", ordered: 24, received: 12 },
596
+ { id: "2", item: "Large carton", ordered: 36, received: null },
597
+ ];
598
+ const [savedRows, setSavedRows] = useState(initialRows);
599
+ const [rows, setRows] = useState(initialRows);
600
+ const [saving, setSaving] = useState(false);
601
+ const [error, setError] = useState<string | null>(null);
602
+ const { column } = createColumnHelper<Line>();
603
+
604
+ async function save() {
605
+ const invalid = rows.find(
606
+ (row) =>
607
+ row.received !== null &&
608
+ (!Number.isInteger(row.received) || row.received < 0 || row.received > row.ordered),
609
+ );
610
+ if (invalid) {
611
+ setError(`${invalid.item}: enter a whole number from 0 to ${invalid.ordered}, or clear it`);
612
+ return;
613
+ }
614
+ // This snapshot contains adopted confirmations, not unfinished editor input.
615
+ const confirmedRows = rows;
616
+ setSaving(true);
617
+ setError(null);
618
+ try {
619
+ // Demo API: replace with a batch request for confirmedRows.
620
+ await new Promise((resolve) => setTimeout(resolve, 400));
621
+ if (confirmedRows.some((row) => row.received === 13)) {
622
+ throw new Error("Batch rejected quantity 13; correct it and try again");
623
+ }
624
+ setSavedRows(confirmedRows);
625
+ } catch (cause) {
626
+ setError(cause instanceof Error ? cause.message : "Save failed");
627
+ } finally {
628
+ setSaving(false);
629
+ }
630
+ }
631
+
632
+ const table = useDataTable({
633
+ data: { rows },
634
+ columns: [
635
+ column({ id: "item", label: "Item", type: "text" }),
636
+ column({ id: "ordered", label: "Ordered", type: "number" }),
637
+ column({
638
+ id: "received",
639
+ label: "Received",
640
+ type: "number",
641
+ edit: {
642
+ canEdit: () => !saving,
643
+ onValueCommitted: (row, value) =>
644
+ setRows((current) =>
645
+ current.map((line) => (line.id === row.id ? { ...line, received: value } : line)),
646
+ ),
647
+ },
648
+ }),
649
+ ],
650
+ });
651
+
652
+ return (
653
+ <div className="space-y-3">
654
+ <p role="status">{saving ? "Saving batch…" : "Confirm edits, then Save or Cancel"}</p>
655
+ {error && <p role="alert">{error}</p>}
656
+ <DataTable.Root value={table}>
657
+ <DataTable.Table />
658
+ </DataTable.Root>
659
+ <div className="flex gap-2">
660
+ <Button disabled={saving} onClick={() => void save()}>
661
+ Save
662
+ </Button>
663
+ <Button
664
+ disabled={saving}
665
+ variant="outline"
666
+ onClick={() => {
667
+ setRows(savedRows);
668
+ setError(null);
669
+ }}
670
+ >
671
+ Cancel
672
+ </Button>
673
+ </div>
674
+ </div>
675
+ );
676
+ }
677
+ ```
678
+
679
+ ## Row selection
680
+
681
+ 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.
682
+
683
+ ### `RowSelectionOptions`
684
+
685
+ | Property | Type | Description |
686
+ | -------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------- |
687
+ | `selectedIds` | `string[]` | Controlled selected ids. Requires `onChange` and cannot be combined with `defaultSelectedIds`. |
688
+ | `defaultSelectedIds` | `string[]` | Initial selected ids in uncontrolled mode. Later changes are ignored. |
689
+ | `onChange` | `(ids: string[]) => void` | Notifies user-driven selection changes; required in controlled mode and optional in uncontrolled mode. |
690
+ | `actions` | `SelectionAction[]` | Optional bulk actions shown in `DataTable.Footer` while ids are selected. Omitted or empty arrays show no bar. |
691
+
692
+ ### Controlled selection
693
+
694
+ Provide `selectedIds` and `onChange` together to own the selection. The `RowSelectionOptions` type requires `onChange` in controlled mode and rejects combining `selectedIds` with `defaultSelectedIds`.
695
+
696
+ ```tsx
697
+ const [selectedIds, setSelectedIds] = useState<string[]>([]);
698
+
699
+ const table = useDataTable<Order>({
700
+ columns,
701
+ data,
702
+ rowSelection: { selectedIds, onChange: setSelectedIds },
703
+ });
704
+ ```
705
+
706
+ 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.
707
+
708
+ ### Uncontrolled selection
709
+
710
+ 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.
711
+
712
+ ```tsx
713
+ const table = useDataTable<Order>({
714
+ columns,
715
+ data,
716
+ rowSelection: { defaultSelectedIds: ["INV-1001"] },
717
+ });
718
+ ```
719
+
720
+ 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.
721
+
722
+ 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.
723
+
724
+ ## Selection actions
725
+
726
+ 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.
727
+
728
+ ```tsx
729
+ const [selectedIds, setSelectedIds] = useState<string[]>([]);
730
+ const [deleteTargetIds, setDeleteTargetIds] = useState<string[] | null>(null);
731
+
732
+ const table = useDataTable<Vendor>({
733
+ columns,
734
+ data,
735
+ control,
736
+ rowSelection: {
737
+ selectedIds,
738
+ onChange: setSelectedIds,
739
+ actions: [
740
+ {
741
+ id: "delete",
742
+ label: "Delete",
743
+ variant: "destructive",
744
+ onClick: (ids) => setDeleteTargetIds([...ids]),
745
+ },
746
+ ],
747
+ },
748
+ });
749
+
750
+ return (
751
+ <>
752
+ <DataTable.Root value={table}>
753
+ <DataTable.Table />
754
+ <DataTable.Footer>
755
+ <DataTable.Pagination />
756
+ </DataTable.Footer>
757
+ </DataTable.Root>
758
+
759
+ {deleteTargetIds !== null && (
760
+ <DeleteVendorsDialog
761
+ ids={deleteTargetIds}
762
+ onCancel={() => setDeleteTargetIds(null)}
763
+ onDeleted={(deletedIds: string[]) => {
764
+ const deleted = new Set(deletedIds);
765
+ setSelectedIds((current) => current.filter((id) => !deleted.has(id)));
766
+ setDeleteTargetIds(null);
767
+ }}
768
+ />
769
+ )}
770
+ </>
771
+ );
772
+ ```
773
+
774
+ `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.
775
+
776
+ - **`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.
777
+ - **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.
778
+ - **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.
779
+ - **Selection spans pages.** The header checkbox adds or removes only the current page; **Clear** empties everything.
780
+ - **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.
781
+ - **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).
782
+ - **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.
783
+ - **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.
784
+
785
+ ### Accessibility
786
+
787
+ 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>`.
788
+
371
789
  ## `useDataTable`
372
790
 
373
- Creates the table state object to pass to `DataTable.Root`.
791
+ 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.
374
792
 
375
793
  ```tsx
376
794
  const table = useDataTable({
@@ -383,19 +801,20 @@ const table = useDataTable({
383
801
 
384
802
  ### Options
385
803
 
386
- | Option | Type | Description |
387
- | ------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
388
- | `columns` | `Column<TRow>[]` | Column definitions. Required. |
389
- | `data` | `DataTableData<TRow> \| undefined` | Fetched data. Pass `undefined` while loading. |
390
- | `loading` | `boolean` | When `true`, renders a loading skeleton. |
391
- | `error` | `Error \| null` | When set, renders an error message in the table body. |
392
- | `control` | `CollectionControl` | Collection control from `useCollectionVariables()`. Required for `DataTable.Pagination` and `DataTable.Filters`. |
393
- | `onClickRow` | `(row: TRow) => void` | Called when the user clicks a row. Adds a pointer cursor to rows. |
394
- | `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. |
395
- | `rowActions` | `RowAction<TRow>[]` | Per-row action items rendered in a kebab-menu column. The column is omitted when empty or not provided. |
396
- | `onSelectionChange` | `(ids: string[]) => void` | Called with selected row IDs on change. Providing this enables the checkbox column. Rows must have a string `id`. |
397
- | `rowExpansion` | `RowExpansionOptions<TRow>` | Expandable detail rows: `render`, plus optional `canExpand` / `getLabel`, and `expandedIds` + `onChange` together for controlled mode. See [Expandable rows](#expandable-rows). |
398
- | `sort` | `false \| { multiple?: boolean }` | Sort behaviour. `false` disables sorting entirely. `{ multiple: true }` enables multi-column sorting. Omit or pass `{}` for single-column sort (default). |
804
+ | Option | Type | Description |
805
+ | ------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
806
+ | `columns` | `Column<TRow>[]` | Column definitions. Required. |
807
+ | `data` | `DataTableData<TRow> \| undefined` | Fetched data. Pass `undefined` while loading. |
808
+ | `loading` | `boolean` | When `true`, renders a loading skeleton. |
809
+ | `error` | `Error \| null` | When set, renders an error message in the table body. |
810
+ | `control` | `CollectionControl` | Collection control from `useCollectionVariables()`. Required for `DataTable.Pagination` and `DataTable.Filters`. |
811
+ | `onClickRow` | `(row: TRow) => void` | Called when the user clicks a row. Adds a pointer cursor to rows. |
812
+ | `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. |
813
+ | `rowActions` | `RowAction<TRow>[]` | Per-row action items rendered in a kebab-menu column. The column is omitted when empty or not provided. |
814
+ | `rowSelection` | `RowSelectionOptions` | Controlled `selectedIds` + `onChange`, or uncontrolled `defaultSelectedIds` + optional `onChange`, with optional `actions`. Providing even `{}` enables checkboxes. See [Row selection](#row-selection). |
815
+ | `onSelectionChange` | `(ids: string[]) => void` | **Deprecated:** use `rowSelection.onChange`. Still supported for compatibility; enables checkboxes and is used when the new callback is absent. |
816
+ | `rowExpansion` | `RowExpansionOptions<TRow>` | Expandable detail rows: `render`, plus optional `canExpand` / `getLabel`, and `expandedIds` + `onChange` together for controlled mode. See [Expandable rows](#expandable-rows). |
817
+ | `sort` | `false \| { multiple?: boolean }` | Sort behaviour. `false` disables sorting entirely. `{ multiple: true }` enables multi-column sorting. Omit or pass `{}` for single-column sort (default). |
399
818
 
400
819
  ### `DataTableData`
401
820
 
@@ -407,35 +826,35 @@ const table = useDataTable({
407
826
 
408
827
  ## `Column`
409
828
 
410
- 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.
829
+ A column definition passed to `useDataTable`. `Column<TRow>` is a discriminated union on `type` — the shapes of `typeOptions`, `accessor`, and optional `edit` are narrowed per branch, so mismatches are compile errors rather than silent runtime no-ops. See [Inline editing](#inline-editing) for typed editors and their change-notification contract.
411
830
 
412
831
  ### Shared fields
413
832
 
414
- | Property | Type | Description |
415
- | ---------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
416
- | `label` | `string` | Column header text. Omit for icon-only columns. |
417
- | `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.). |
418
- | `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`). |
419
- | `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. |
420
- | `width` | `number` | Fixed column width in pixels. Optional. |
421
- | `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. |
422
- | `align` | `"left" \| "right"` | Horizontal alignment. Defaults to `"right"` for `type: "number"` and `type: "money"`; `"left"` otherwise. Pass `"left"` to opt a numeric column out. |
423
- | `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). |
424
- | `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. |
425
- | `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. |
426
- | `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. |
833
+ | Property | Type | Description |
834
+ | ---------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
835
+ | `label` | `string` | Column header text. Omit for icon-only columns. |
836
+ | `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.). |
837
+ | `render` | `(row: TRow) => ReactNode` | Renders the cell content. Presentation only. Optional — overrides the built-in `type` display renderer when set, not an editor configured through `edit`. Leave `edit` unset to draw your own editor with `render`. 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`). |
838
+ | `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. |
839
+ | `width` | `number` | Fixed column width in pixels. Optional. |
840
+ | `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. |
841
+ | `align` | `"left" \| "right"` | Horizontal alignment. Defaults to `"right"` for `type: "number"` and `type: "money"`; `"left"` otherwise. Pass `"left"` to opt a numeric column out. |
842
+ | `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). |
843
+ | `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. |
844
+ | `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. |
845
+ | `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. |
427
846
 
428
847
  ### `type`-specific fields
429
848
 
430
- | `type` | `typeOptions` |
431
- | ----------- | ------------------------------------------------------------ |
432
- | _(omitted)_ | _(not allowed; provide `render` to draw the cell)_ |
433
- | `"text"` | _(not allowed)_ |
434
- | `"number"` | `NumberCellOptions` |
435
- | `"money"` | `MoneyCellOptions<TRow>` |
436
- | `"date"` | `DateCellOptions` |
437
- | `"badge"` | `BadgeCellOptions` |
438
- | `"link"` | **Required** — `LinkCellOptions<TRow>` (must include `href`) |
849
+ | `type` | `typeOptions` | Optional `edit` |
850
+ | ----------- | ------------------------------------------------------------ | ----------------------------- |
851
+ | _(omitted)_ | _(not allowed; provide `render` to draw the cell)_ | _(not allowed)_ |
852
+ | `"text"` | _(not allowed)_ | `TextCellEditOptions<TRow>` |
853
+ | `"number"` | `NumberCellOptions` | `NumberCellEditOptions<TRow>` |
854
+ | `"money"` | `MoneyCellOptions<TRow>` | `NumberCellEditOptions<TRow>` |
855
+ | `"date"` | `DateCellOptions` | `DateCellEditOptions<TRow>` |
856
+ | `"badge"` | `BadgeCellOptions` | `BadgeCellEditOptions<TRow>` |
857
+ | `"link"` | **Required** — `LinkCellOptions<TRow>` (must include `href`) | `TextCellEditOptions<TRow>` |
439
858
 
440
859
  ## Cell types
441
860
 
@@ -693,18 +1112,75 @@ The `filter` property on a column accepts the same base shape as `FilterConfig`,
693
1112
 
694
1113
  ### Filter Types and Operators
695
1114
 
696
- | Type | Input editor | Supported operators |
697
- | ---------- | ------------------------- | ------------------------------------------------------------------------------------------------------------ |
698
- | `string` | Text | `eq`, `ne`, `contains`, `notContains`, `hasPrefix`, `hasSuffix`, `notHasPrefix`, `notHasSuffix`, `in`, `nin` |
699
- | `number` | Number | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` |
700
- | `datetime` | Datetime-local | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` |
701
- | `date` | **Calendar / DatePicker** | `eq` (_exact date_), `gte` (_after_), `lte` (_before_), **`between`** |
702
- | `time` | Time | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` |
703
- | `enum` | Dropdown | `eq`, `ne`, `in`, `nin` |
704
- | `boolean` | Toggle | `eq`, `ne` |
705
- | `uuid` | Text | `eq`, `ne`, `in`, `nin` |
1115
+ | Type | Input editor | Supported operators |
1116
+ | ---------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------ |
1117
+ | `string` | Text | `eq`, `ne`, `contains`, `notContains`, `hasPrefix`, `hasSuffix`, `notHasPrefix`, `notHasSuffix`, `in`, `nin` |
1118
+ | `number` | Number | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` |
1119
+ | `decimal` | Decimal text (string value) | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` |
1120
+ | `datetime` | Calendar / DatePicker + Time | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` |
1121
+ | `date` | **Calendar / DatePicker** | `eq` (_exact date_), `gte` (_after_), `lte` (_before_), **`between`** |
1122
+ | `time` | Time | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` |
1123
+ | `enum` | Dropdown | `eq`, `ne`, `in`, `nin` |
1124
+ | `boolean` | Toggle | `eq`, `ne` |
1125
+ | `uuid` | Text | `eq`, `ne`, `in`, `nin` |
1126
+
1127
+ 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.
1128
+
1129
+ ### Decimal Filters
706
1130
 
707
- When the `between` operator is selected on a `number`, `datetime`, `date`, or `time` column, the value editor renders a range input with **min**/**max** (or **From**/**To** for dates) bounds.
1131
+ 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`.
1132
+
1133
+ 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.
1134
+
1135
+ 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.
1136
+
1137
+ ### Datetime Filters and Business Timezones
1138
+
1139
+ > **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.
1140
+
1141
+ `locale` and `timeZone` are independent:
1142
+
1143
+ | Setting | Controls | Example |
1144
+ | ---------- | ------------------------------------------------------------------------------------ | -------------- |
1145
+ | `locale` | UI language and locale-specific date/time formatting | `"ja-JP"` |
1146
+ | `timeZone` | The timezone used to interpret the date and time selected in datetime filter editors | `"Asia/Tokyo"` |
1147
+
1148
+ 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:
1149
+
1150
+ ```tsx
1151
+ import { AppShell } from "@tailor-platform/app-shell";
1152
+
1153
+ <AppShell modules={modules} locale="ja-JP" timeZone="America/New_York">
1154
+ {/* Existing application content */}
1155
+ </AppShell>;
1156
+ ```
1157
+
1158
+ 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`.
1159
+
1160
+ #### Local input, RFC 3339 output
1161
+
1162
+ 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:
1163
+
1164
+ | Timezone used by the editor | Serialized filter value |
1165
+ | --------------------------- | -------------------------- |
1166
+ | `Asia/Tokyo` | `2026-10-08T15:00:00.000Z` |
1167
+ | `America/New_York` | `2026-10-09T04:00:00.000Z` |
1168
+
1169
+ 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.
1170
+
1171
+ 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.
1172
+
1173
+ 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.
1174
+
1175
+ 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.
1176
+
1177
+ 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.
1178
+
1179
+ #### Calendar dates are not instants
1180
+
1181
+ 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.
1182
+
1183
+ 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.
708
1184
 
709
1185
  ### Date Filters
710
1186
 
@@ -717,7 +1193,7 @@ When the `between` operator is selected on a `number`, `datetime`, `date`, or `t
717
1193
  | `lte` | _before_ | on or before (inclusive) |
718
1194
  | `between` | _between_ | inclusive min–max range |
719
1195
 
720
- `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 and native inputs.)
1196
+ `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.)
721
1197
 
722
1198
  ### String Filter Case Sensitivity
723
1199
 
@@ -743,14 +1219,56 @@ When `caseSensitive` is omitted or `false`, the filter is case-insensitive. When
743
1219
 
744
1220
  ## `RowAction`
745
1221
 
746
- | Property | Type | Description |
747
- | ------------ | ---------------------------- | ---------------------------------------------------- |
748
- | `id` | `string` | Stable identifier for the action. |
749
- | `label` | `string` | Display label in the kebab menu. |
750
- | `icon` | `ReactNode` | Optional icon shown beside the label. |
751
- | `variant` | `"default" \| "destructive"` | Visual style of the menu item. |
752
- | `isDisabled` | `(row: TRow) => boolean` | Return `true` to disable the action for a given row. |
753
- | `onClick` | `(row: TRow) => void` | Called when the action is clicked. |
1222
+ 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.
1223
+
1224
+ | Property | Type | Description |
1225
+ | ------------ | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
1226
+ | `canApply` | `(row: TRow) => boolean` | Return `false` to disable the action for a given row. |
1227
+ | `isDisabled` | `(row: TRow) => boolean` | **Deprecated** — use `canApply`, which reads the other way round. Still honoured: the action is disabled if either one says so. |
1228
+ | `onClick` | `(row: TRow) => void` | Called when the action is clicked. |
1229
+
1230
+ ## `SelectionAction`
1231
+
1232
+ A non-generic bulk action for selected ids. Extends [`DataTableAction`](#datatableaction). See [Selection actions](#selection-actions).
1233
+
1234
+ | Property | Type | Description |
1235
+ | --------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1236
+ | `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. |
1237
+
1238
+ `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.
1239
+
1240
+ ## `DataTableAction`
1241
+
1242
+ 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.
1243
+
1244
+ | Property | Type | Description |
1245
+ | --------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------- |
1246
+ | `id` | `string` | Stable identifier for the action. |
1247
+ | `label` | `string` | Display label — the menu item for a row action, the button (or **More actions** item) for a selection action. |
1248
+ | `icon` | `ReactNode` | Optional icon shown beside the label. |
1249
+ | `variant` | `"default" \| "destructive"` | Visual style. A destructive action still needs its own confirmation step. |
1250
+
1251
+ ```tsx
1252
+ const archive: DataTableAction = {
1253
+ id: "archive",
1254
+ label: "Archive",
1255
+ };
1256
+
1257
+ useDataTable<Order>({
1258
+ columns,
1259
+ data,
1260
+ rowActions: [
1261
+ {
1262
+ ...archive,
1263
+ canApply: (order) => order.status !== "Archived",
1264
+ onClick: (order) => archiveOrders([order.id]),
1265
+ },
1266
+ ],
1267
+ rowSelection: {
1268
+ actions: [{ ...archive, onClick: (ids) => archiveOrders(ids) }],
1269
+ },
1270
+ });
1271
+ ```
754
1272
 
755
1273
  ## `createColumnHelper`
756
1274
 
@@ -804,19 +1322,21 @@ const { variables, control } = useCollectionVariables({
804
1322
  });
805
1323
 
806
1324
  // variables.pagination → { first, after? } or { last, before? }
807
- // variables.query → filter input object or undefined
1325
+ // variables.query → filter input object (undefined when no filters are active)
808
1326
  // variables.order → sort input array or undefined
809
1327
  ```
810
1328
 
1329
+ When you need an object for `Object.keys` or destructuring, use `variables.query ?? {}`.
1330
+
811
1331
  ### Options
812
1332
 
813
- | Option | Type | Description |
814
- | ----------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
815
- | `params.pageSize` | `number` | Initial page size. Default: `20`. |
816
- | `params.initialFilters` | `Filter[]` | Filters applied on first render. |
817
- | `params.initialSort` | `SortState[]` | Sort applied on first render. |
818
- | `tableMetadata` | `TableMetadata` | Generated table metadata. Required for typed GraphQL documents (see [Typed query variables](#typed-query-variables)). |
819
- | `onParamsChange` | `(params: CollectionParams) => void` | Called after each filter, sort, or page-size change with the current params. |
1333
+ | Option | Type | Description |
1334
+ | ----------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
1335
+ | `params.pageSize` | `number` | Initial page size. Default: `20`. |
1336
+ | `params.initialFilters` | `Filter[]` | Filters applied on first render. |
1337
+ | `params.initialSort` | `SortState[]` | Sort applied on first render. |
1338
+ | `tableMetadata` | `TableMetadata` | Generated table metadata. Enables typed variables and datetime normalization (see [Datetime filters](#datetime-filters-and-business-timezones)). |
1339
+ | `onParamsChange` | `(params: CollectionParams) => void` | Called after each filter, sort, or page-size change with the current params. |
820
1340
 
821
1341
  ### Return Value
822
1342
 
@@ -839,7 +1359,7 @@ const { variables, control } = useCollectionVariables({
839
1359
  params: { pageSize: 20 },
840
1360
  });
841
1361
 
842
- // variables.query is now BuildQueryVariables<typeof tableMetadata.order>
1362
+ // variables.query is now BuildQueryVariables<typeof tableMetadata.order> | undefined
843
1363
  // variables.order is now { field: OrderableFieldName; direction: "Asc" | "Desc" }[]
844
1364
  const [result] = useQuery({
845
1365
  query: LIST_ORDERS, // TypedDocumentNode — variables are fully type-checked
@@ -870,7 +1390,7 @@ The return value is identical to `useCollectionVariables` — `variables` and `c
870
1390
 
871
1391
  ### Options
872
1392
 
873
- 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).
1393
+ 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).
874
1394
 
875
1395
  ### URL format
876
1396
 
@@ -912,6 +1432,8 @@ function MyCustomPagination() {
912
1432
  }
913
1433
  ```
914
1434
 
1435
+ 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.
1436
+
915
1437
  ## SDK Plugin (`@tailor-platform/sdk-plugin-app-shell`)
916
1438
 
917
1439
  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).