@tailor-platform/app-shell 1.17.0 → 1.19.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 (36) hide show
  1. package/dist/app-shell-core.css +1 -1
  2. package/dist/app-shell.d.ts +503 -137
  3. package/dist/app-shell.js +8847 -7378
  4. package/dist/{appshell-CahhZy7v.js → appshell-DQTz-QwK.js} +489 -379
  5. package/dist/style-entry.d.ts +1 -656
  6. package/dist/testing.d.ts +118 -113
  7. package/dist/testing.js +1 -1
  8. package/dist/theme.bridge.css +127 -40
  9. package/dist/theme.css +4 -3
  10. package/dist/themes/_template.css +5 -6
  11. package/dist/themes/bloom.css +0 -2
  12. package/dist/themes/cream.css +0 -2
  13. package/dist/themes/default.css +112 -77
  14. package/dist/vite-plugin.d.ts +118 -113
  15. package/package.json +8 -5
  16. package/skills/app-shell-patterns/SKILL.md +51 -32
  17. package/skills/app-shell-patterns/references/api/aggregations.md +131 -0
  18. package/skills/app-shell-patterns/references/api/define-i18n-labels.md +123 -91
  19. package/skills/app-shell-patterns/references/api/define-module.md +2 -4
  20. package/skills/app-shell-patterns/references/components/action-panel.md +55 -23
  21. package/skills/app-shell-patterns/references/components/badge.md +7 -3
  22. package/skills/app-shell-patterns/references/components/data-table.md +724 -36
  23. package/skills/app-shell-patterns/references/components/form.md +19 -9
  24. package/skills/app-shell-patterns/references/components/item-list.md +191 -0
  25. package/skills/app-shell-patterns/references/components/select.md +10 -0
  26. package/skills/app-shell-patterns/references/components/sidebar-group.md +6 -6
  27. package/skills/app-shell-patterns/references/components/text.md +235 -0
  28. package/skills/app-shell-patterns/references/concepts/sidebar-navigation.md +16 -14
  29. package/skills/app-shell-patterns/references/concepts/styling-theming.md +46 -22
  30. package/skills/app-shell-patterns/references/migrations.md +23 -2
  31. package/skills/app-shell-patterns/references/pages/document-detail.md +36 -25
  32. package/skills/app-shell-patterns/references/patterns/form-modal.md +4 -1
  33. package/skills/app-shell-patterns/references/patterns/i18n-labels.md +139 -0
  34. package/skills/app-shell-patterns/references/patterns/list-dense-scan.md +3 -2
  35. package/dist/semantic-contrast.test.ts +0 -154
  36. package/dist/theme-bridge.test.ts +0 -159
@@ -13,6 +13,7 @@ description: Compound data table component with sortable columns, filter chips,
13
13
 
14
14
  ```tsx
15
15
  import {
16
+ CellEditError,
16
17
  DataTable,
17
18
  useDataTable,
18
19
  useDataTableContext,
@@ -30,6 +31,13 @@ import {
30
31
  type UseDataTableReturn,
31
32
  type MetadataFieldOptions,
32
33
  type DataTableContextValue,
34
+ type TextCellEditOptions,
35
+ type NumberCellEditOptions,
36
+ type BadgeCellEditOptions,
37
+ type DateCellEditOptions,
38
+ type CellEditState,
39
+ type DateTimeDraft,
40
+ type FooterRenderContext,
33
41
  } from "@tailor-platform/app-shell";
34
42
  ```
35
43
 
@@ -147,15 +155,15 @@ function JournalsPage() {
147
155
 
148
156
  `DataTable` is a namespace object. All sub-components read state from `DataTable.Root` via context.
149
157
 
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`. |
158
+ | Sub-component | Description |
159
+ | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
160
+ | `DataTable.Root` | Context provider. Wraps all other sub-components. Required. |
161
+ | `DataTable.Table` | Renders the `<table>` with headers, body, and an optional [column footer](#column-footers). Required. |
162
+ | `DataTable.Toolbar` | DataTable-specific toolbar container. Optional; pass `columnSettings` for the built-in Columns control. |
163
+ | `DataTable.ColumnSettings` | The placeable Columns control (show/hide + reorder + pin). Use it in a generic [`Toolbar`](./toolbar.md) for custom layouts. |
164
+ | `DataTable.Filters` | Add-filter panel + active filter chips, auto-generated from column filter configs. Requires `control` from `useCollectionVariables`. |
165
+ | `DataTable.Footer` | Container outside the `<table>` for pagination and other content, not the column footer. Optional. Becomes the bulk-action bar while rows are selected — see [Selection actions](#selection-actions). |
166
+ | `DataTable.Pagination` | Pre-built pagination controls with optional row count and selection info. Requires `control` from `useCollectionVariables`. Place inside `DataTable.Footer`. |
159
167
 
160
168
  ### `DataTable.Root` Props
161
169
 
@@ -255,6 +263,329 @@ const table = useDataTable<Order>({
255
263
  </DataTable.Root>;
256
264
  ```
257
265
 
266
+ ## Per-cell styling
267
+
268
+ Use `cellProps(row)` to set `className`, `style`, and `title` on the actual data-cell `<td>`, including its padding. Return `undefined` to keep the defaults. This works with built-in renderers, custom `render`, truncation tooltips, and editable cells. It does not customize headers, footers, loading placeholders, empty/error states, or the built-in selection, expansion, and action cells. Content stays owned by `render`; events, refs, and cell spanning are not supported through `cellProps`.
269
+
270
+ An explicit `style.backgroundColor` stays visible during row hover and selection, for both scrollable and pinned columns. Cells returning `undefined` retain the default hover/selection backgrounds; checkboxes and row selection semantics are unchanged. Classes are merged after built-in classes, but CSS specificity still applies: pinned cells have their own group-hover and group-aria-selected backgrounds, which a plain background class does not override. Use `style.backgroundColor` when an informational background must remain visible in every state. Keep the value or another non-color cue visible so color is not the only way to understand the cell.
271
+
272
+ Column `width` and the pinned cell's sticky position/edge offset take precedence over `cellProps.style`. Use this hook for presentation rather than layout overrides. Pinned backgrounds must be opaque to hide scrolling content; the example uses existing solid/contrast token pairs rather than translucent surface tokens.
273
+
274
+ ### Header styling
275
+
276
+ Use `headerProps(ctx)` to return `className`, `style`, and/or `title` to customize the actual `<th>`, including its padding. Like `cellProps(row)`, return `undefined` to keep the defaults. It receives the same `HeaderRenderContext` as `header(ctx)`: `label` and `sortable`, plus `sortDirection` and `activateSort()` when sortable. It works with the default header or a custom `header(ctx)` renderer; styling alone does not replace the built-in sort button or context menu.
277
+
278
+ Classes are merged after built-in classes, and column `width` and sticky position/edge offset take precedence over the returned `style`, just as for body cells. Use opaque backgrounds for pinned headers. Events, refs, content, and cell spanning are not supported through `headerProps`; built-in selection, expansion, and action headers are unaffected.
279
+
280
+ <example-preview name="per-cell-styling"></example-preview>
281
+
282
+ ```tsx
283
+ function PerCellStyling() {
284
+ type Overtime = { id: string; employee: string; january: number; february: number };
285
+ const { column } = createColumnHelper<Overtime>();
286
+ const overtimeStyle = (hours: number) => {
287
+ const tone = hours >= 60 ? "danger" : hours >= 40 ? "warning" : "success";
288
+ return {
289
+ backgroundColor: `var(--${tone}-solid)`,
290
+ color: `var(--${tone}-contrast)`,
291
+ };
292
+ };
293
+ const table = useDataTable({
294
+ data: {
295
+ rows: [
296
+ { id: "1", employee: "Alex", january: 24, february: 48 },
297
+ { id: "2", employee: "Sam", january: 45, february: 64 },
298
+ { id: "3", employee: "Robin", january: 62, february: 32 },
299
+ ],
300
+ },
301
+ columns: [
302
+ column({ id: "employee", label: "Employee", type: "text", width: 160 }),
303
+ column({
304
+ id: "january",
305
+ label: "January (hours)",
306
+ type: "number",
307
+ width: 180,
308
+ cellProps: (row) => ({
309
+ style: overtimeStyle(row.january),
310
+ title: `${row.january} hours of overtime`,
311
+ }),
312
+ }),
313
+ column({
314
+ id: "february",
315
+ label: "February (hours)",
316
+ type: "number",
317
+ width: 180,
318
+ pin: "right",
319
+ headerProps: () => ({
320
+ className: "astw:whitespace-nowrap",
321
+ style: { backgroundColor: "var(--muted)" },
322
+ title: "February overtime summary",
323
+ }),
324
+ cellProps: (row) => ({
325
+ className: "astw:tabular-nums",
326
+ style: overtimeStyle(row.february),
327
+ title: `${row.february} hours of overtime`,
328
+ }),
329
+ }),
330
+ ],
331
+ rowSelection: {},
332
+ });
333
+
334
+ return (
335
+ <div className="space-y-3">
336
+ <p>Overtime: below 40 hours is safe, 40–59 is caution, 60 or more is danger.</p>
337
+ <DataTable.Root value={table}>
338
+ <DataTable.Table />
339
+ </DataTable.Root>
340
+ </div>
341
+ );
342
+ }
343
+ ```
344
+
345
+ ## Column footers
346
+
347
+ Set `column.footer` to a static string such as `footer: "Total"` or a renderer to show content beneath that column without adding synthetic rows to `data.rows`. The [`aggregations`](../api/aggregations.md) helpers create ordinary footer renderers from typed selectors. `DataTable.Table` automatically renders one `<tfoot>` row when at least one visible column defines a footer, including an empty string. Fixed header text still uses `column.label`; `column.header` remains a custom renderer that replaces the built-in header UI.
348
+
349
+ <example-preview name="column-footers"></example-preview>
350
+
351
+ ```tsx
352
+ function ColumnFooters() {
353
+ type Line = { id: string; item: string; quantity: number | null };
354
+ const [rows, setRows] = useState<Line[]>([
355
+ { id: "1", item: "Small carton", quantity: 2 },
356
+ { id: "2", item: "Large carton", quantity: 3 },
357
+ ]);
358
+ const { column } = createColumnHelper<Line>();
359
+ const table = useDataTable({
360
+ data: { rows },
361
+ columns: [
362
+ column({
363
+ id: "item",
364
+ label: "Item",
365
+ type: "text",
366
+ footer: "Total",
367
+ }),
368
+ column({
369
+ id: "quantity",
370
+ label: "Quantity",
371
+ type: "number",
372
+ edit: {
373
+ onValueCommitted: (row, quantity) =>
374
+ setRows((current) =>
375
+ current.map((line) => (line.id === row.id ? { ...line, quantity } : line)),
376
+ ),
377
+ },
378
+ footer: aggregations.sum((row) => row.quantity),
379
+ footerProps: ({ rows }) =>
380
+ rows.length > 0
381
+ ? {
382
+ className: "tabular-nums",
383
+ style: {
384
+ backgroundColor: "var(--success-solid)",
385
+ color: "var(--success-contrast)",
386
+ },
387
+ title: `Quantity total for ${rows.length} items on this page`,
388
+ }
389
+ : undefined,
390
+ }),
391
+ ],
392
+ });
393
+
394
+ return (
395
+ <DataTable.Root value={table}>
396
+ <DataTable.Table />
397
+ </DataTable.Root>
398
+ );
399
+ }
400
+ ```
401
+
402
+ - **Current rows, not all records.** `ctx.rows` is the current `data.rows` array exposed as read-only, so a paginated table computes a page total. Selection does not narrow it. For a server-calculated total across all pages, use a closure such as `footer: () => totalQuantity` instead.
403
+ - **Current adopted values.** Updating `data.rows`, including adopting a confirmed cell edit, updates the footer. Unconfirmed editor drafts are not included, and footer callbacks must not mutate rows.
404
+ - **Consumer-owned content and formatting.** A string is displayed as-is; renderers can return text, numbers, or JSX. `type` and `typeOptions` do not format footer results. A column without `footer`, or a renderer returning `null`, leaves an empty cell.
405
+ - **Do not sum mixed currencies.** A `money` column can resolve its currency per row, so check that all amounts being summed use the same currency before displaying a monetary total. If multiple currencies are present, return a placeholder such as `"N/A"` or `"–"` instead of adding the amounts together. Currency checks remain the consumer's responsibility; see [Composing with business validation](#composing-with-business-validation) for how to guard an aggregation. The table does not convert currencies or validate footer totals.
406
+ - **Shared column layout.** Footer cells follow column width, alignment, ordering, visibility, and left/right pinning. Hidden columns' footer callbacks are not called. The row sticks to the bottom of the table's scroll container during vertical scrolling, paired with the sticky header at the top.
407
+ - **Not data rows.** The footer has no generated editors, selection checkboxes, expansion triggers, or row-action menus; their utility columns have empty footer cells to preserve alignment. Footer cells do not trigger `onClickRow`.
408
+ - **Loading and empty states.** Loading keeps the footer row mounted and replaces defined footer content with skeletons without calling footer renderers, including during page changes. This preserves the footer region without showing stale totals or calculating from incomplete data. Errors hide the footer. Loaded empty data still calls visible footer renderers with `rows: []`; the consumer chooses whether to return zero, a dash, or `null` for a blank cell. The table does not substitute these values. Check `rows.length` to distinguish no records from records whose total is zero. If no visible column defines `footer`, no `<tfoot>` is rendered.
409
+
410
+ ### Footer styling
411
+
412
+ Use `footerProps(ctx)` to return `className`, `style`, and/or `title` for the actual footer `<td>`, including its padding. It receives the same `FooterRenderContext<TRow>` as `footer(ctx)`, with the current page's read-only `rows`. Return `undefined` to keep the defaults. It works with static strings, custom footer renderers, and blank footer cells when another visible column defines `footer`; `footerProps` alone does not create a footer row.
413
+
414
+ Classes are merged after built-in classes; column `width` and sticky position/edge offset take precedence over the returned `style`. Use opaque backgrounds for pinned footer cells. Content stays owned by `footer`; events, refs, and cell spanning are not supported. Built-in selection, expansion, and action footer cells and the separate `DataTable.Footer` container are unaffected.
415
+
416
+ Footer styling callbacks are not called while loading, on errors, or for hidden columns. Loaded empty data calls them with `rows: []`, and styles are recomputed when `data.rows` changes.
417
+
418
+ ### Aggregation helpers
419
+
420
+ Import [`aggregations`](../api/aggregations.md) and pass a required row selector to `.sum()`, `.avg()`, `.min()`, `.max()`, or `.count()`. For example, `footer: aggregations.sum((row) => row.quantity)` returns an ordinary footer renderer that selects and sums quantities from the current `ctx.rows`. Inline selectors infer the row type from the surrounding column definition. The same callback can be invoked outside DataTable with `{ rows }`.
421
+
422
+ The selector determines the source independently of the column's `id`, `accessor`, and `render`. To aggregate a computed cell value, explicitly use the same calculation in the selector; the table does not derive or apply it automatically.
423
+
424
+ <example-preview name="column-aggregations"></example-preview>
425
+
426
+ ```tsx
427
+ function ColumnAggregations() {
428
+ type Line = {
429
+ id: string;
430
+ item: string;
431
+ quantity: number | null;
432
+ weightKg: number | null;
433
+ discount: number;
434
+ unitPrice: number;
435
+ note: string | null;
436
+ };
437
+ const rows: Line[] = [
438
+ {
439
+ id: "1",
440
+ item: "Small carton",
441
+ quantity: 2,
442
+ weightKg: 1.25,
443
+ discount: 0,
444
+ unitPrice: 12,
445
+ note: null,
446
+ },
447
+ {
448
+ id: "2",
449
+ item: "Large carton",
450
+ quantity: 3,
451
+ weightKg: 2.5,
452
+ discount: 5,
453
+ unitPrice: 10,
454
+ note: "Ready",
455
+ },
456
+ ];
457
+ const { column } = createColumnHelper<Line>();
458
+ const table = useDataTable({
459
+ data: { rows },
460
+ columns: [
461
+ column({ id: "item", label: "Item", type: "text", footer: "Aggregates" }),
462
+ column({
463
+ id: "quantity",
464
+ label: "Quantity",
465
+ type: "number",
466
+ footer: aggregations.sum((row) => row.quantity),
467
+ }),
468
+ column({
469
+ id: "weightKg",
470
+ label: "Weight (kg)",
471
+ type: "number",
472
+ typeOptions: { maxDecimals: 3 },
473
+ footer: aggregations.avg(
474
+ (row) => row.weightKg,
475
+ (average) => <strong>{average?.toFixed(3) ?? "–"}</strong>,
476
+ ),
477
+ }),
478
+ column({
479
+ id: "discount",
480
+ label: "Discount %",
481
+ type: "number",
482
+ footer: aggregations.max((row) => row.discount),
483
+ }),
484
+ column({
485
+ id: "unitPrice",
486
+ label: "Unit price",
487
+ type: "number",
488
+ footer: aggregations.min((row) => row.unitPrice),
489
+ }),
490
+ column({
491
+ id: "note",
492
+ label: "Note",
493
+ type: "text",
494
+ footer: aggregations.count(
495
+ (row) => row.note,
496
+ (count) => `${count} with notes`,
497
+ ),
498
+ }),
499
+ ],
500
+ });
501
+
502
+ return (
503
+ <DataTable.Root value={table}>
504
+ <DataTable.Table />
505
+ </DataTable.Root>
506
+ );
507
+ }
508
+ ```
509
+
510
+ | Helper | Values | No non-null values |
511
+ | ----------------- | --------------------------------------------------------------------------- | ------------------ |
512
+ | `sum(selector)` | Sum of finite numbers | `0` |
513
+ | `avg(selector)` | Average of finite numbers; nullish values are excluded from the denominator | `null` |
514
+ | `min(selector)` | Minimum finite number; no date or string comparison | `null` |
515
+ | `max(selector)` | Maximum finite number; no date or string comparison | `null` |
516
+ | `count(selector)` | Number of non-nullish values of any type | `0` |
517
+
518
+ - **Missing and invalid values.** All helpers skip selected `null` and `undefined` values. Numeric helpers return `null` for the entire aggregate if any non-nullish selected value is not a finite JavaScript number, including numeric strings, `NaN`, and infinities. They do not coerce values or display a partial total. A non-finite calculated result also returns `null`. `count(selector)` counts selected values such as `0`, `false`, and the empty string; it is not a truthiness check or a count of all rows.
519
+ - **Plain output by default.** Without a transformer, the result is displayed as a number, or a blank cell for `null`. Column `type` and `typeOptions` do not format it. Pass an optional second argument such as `aggregations.sum((row) => row.quantity, (value) => value === null ? "–" : value + " units")` to customize the output. Numeric transformers receive `number | null`; count transformers receive `number`. Transformers receive only the result and run once per invocation, including empty and invalid input. For more involved logic, use a custom `footer: (ctx) => ...` callback.
520
+ - **Typed selectors with runtime validation.** Numeric selectors must return `number | null | undefined`; selecting a string, mixed, or unknown value is a type error regardless of the column's presentation `type`. Runtime validation remains in place for untrusted values, `NaN`, and infinities. `count(selector)` accepts values of any type. Keep selectors and transformers pure; exceptions from them propagate to the caller.
521
+ - **Same footer lifecycle.** The callback receives the current page's adopted `data.rows`, not only selected rows or records on other pages. Loading displays skeletons without evaluating selectors or transformers. Aggregations do not add synthetic records, row interactions, or a new formatting system. Only callbacks with renderable output can be assigned to `footer`; object-producing transformers remain valid outside DataTable.
522
+
523
+ #### Composing with business validation
524
+
525
+ Use a normal footer callback to check relationships between rows before invoking an aggregation. In this example, the outer footer rejects mixed currencies with a descriptive message, `sumAmount` performs the numeric calculation, and its transformer handles formatting or an invalid numeric result. The transformer is not an error handler and does not receive row context.
526
+
527
+ Only rows with non-nullish amounts participate in the currency check, matching the aggregation's nullish-value handling. Toggle **Mix currencies** to switch between a valid total and a mixed-currency message. No currency conversion or new aggregation API is involved.
528
+
529
+ <example-preview name="composed-aggregations"></example-preview>
530
+
531
+ ```tsx
532
+ function ComposedAggregations() {
533
+ type Line = { id: string; amount: number | null; currency: "JPY" | "USD" };
534
+ const [mixed, setMixed] = useState(false);
535
+ const rows: Line[] = [
536
+ { id: "1", amount: 1000, currency: "JPY" },
537
+ { id: "2", amount: 500, currency: mixed ? "USD" : "JPY" },
538
+ { id: "3", amount: null, currency: "USD" },
539
+ ];
540
+ const sumAmount = aggregations.sum(
541
+ (row: Line) => row.amount,
542
+ (total) => (total === null ? "Invalid amount" : total.toLocaleString("en-US")),
543
+ );
544
+ const { column } = createColumnHelper<Line>();
545
+ const table = useDataTable({
546
+ data: { rows },
547
+ columns: [
548
+ column({ id: "id", label: "Line", type: "text" }),
549
+ column({
550
+ id: "amount",
551
+ label: "Amount",
552
+ type: "money",
553
+ typeOptions: { currency: (row) => row.currency },
554
+ footer: (ctx) => {
555
+ const currencies = new Set(
556
+ ctx.rows.filter((row) => row.amount != null).map((row) => row.currency),
557
+ );
558
+ if (currencies.size > 1) {
559
+ return `Mixed currencies: ${[...currencies].join(", ")}`;
560
+ }
561
+ return sumAmount(ctx);
562
+ },
563
+ }),
564
+ ],
565
+ });
566
+
567
+ return (
568
+ <div className="space-y-3">
569
+ <Button variant="outline" aria-pressed={mixed} onClick={() => setMixed((value) => !value)}>
570
+ Mix currencies
571
+ </Button>
572
+ <DataTable.Root value={table}>
573
+ <DataTable.Table />
574
+ </DataTable.Root>
575
+ </div>
576
+ );
577
+ }
578
+ ```
579
+
580
+ ### Column footer vs. table footer
581
+
582
+ These APIs use “footer” at different scopes:
583
+
584
+ - **Column footer — `column.footer`.** Content for a column-aligned cell inside the table's `<tfoot>`, such as a label, total, or note. It shares the column's layout, including width, alignment, and pinning.
585
+ - **Table footer — `DataTable.Footer`.** A container outside the `<table>` for pagination and other table-level content. It becomes the bulk-action bar while rows are selected.
586
+
587
+ `DataTable.Table` renders column footers automatically; no additional JSX component is needed. `DataTable.Footer` does not render or configure column footers, and either region can be used without the other.
588
+
258
589
  ## Context menus
259
590
 
260
591
  `DataTable.Table` adds right-click context menus to both headers and cells:
@@ -266,7 +597,7 @@ const table = useDataTable<Order>({
266
597
 
267
598
  `DataTable` treats column configuration as two layers:
268
599
 
269
- - **Presentation** — `column.header` and `column.render` control what is rendered.
600
+ - **Presentation** — `column.header`, `column.render`, and `column.footer` control rendered content; `column.headerProps`, `column.cellProps`, and `column.footerProps` customize the surrounding header, data-cell, and footer elements.
270
601
  - **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
602
 
272
603
  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.
@@ -371,6 +702,356 @@ The trigger is a native `<button>`, so Enter/Space activation and the focus ring
371
702
 
372
703
  **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
704
 
705
+ ## Inline editing
706
+
707
+ Give a typed column an `edit` config to use its built-in editor. This standardizes the editing UI — draft input, IME, keyboard movement, confirmation, and callback-error presentation. Validation rules and persistence remain consumer-owned; by default, the editor retains a draft until its callbacks complete successfully. Columns without `edit` keep their existing behavior.
708
+
709
+ | Column type | Editor | `onValueChange` draft | `onValueCommitted` output |
710
+ | ------------------------------------ | ----------------------------------------- | ----------------------------------------------------------------- | -------------------------------------- |
711
+ | `text` / `link` | Direct text input | Raw `string`, including `""` | `string \| null` |
712
+ | `number` / `money` | Direct numeric input | Raw `string`, including incomplete input such as `"-"` | JavaScript `number \| null` |
713
+ | `text` / `link` with `edit.options` | Searchable dropdown | Selected string `value`, or `null`; not search text or highlights | Selected string `value`, or `null` |
714
+ | `badge` | Searchable dropdown using the same badges | Selected string `value`, or `null`; not search text or highlights | Selected string `value`, or `null` |
715
+ | `date` | Calendar | `"YYYY-MM-DD" \| null` | `"YYYY-MM-DD" \| null` |
716
+ | `date` with `dateFormat: "datetime"` | Calendar and time field | Complete `DateTimeDraft` snapshot | UTC ISO timestamp, or `null` for Clear |
717
+
718
+ 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.
719
+
720
+ For example, inside a receipt page, accept or reject each proposed quantity yourself. Throw `CellEditError` for invalid quantities to retain the draft and show the message in the cell's error popover. Patch the changed field with a functional state update rather than replacing a whole row captured before another edit:
721
+
722
+ ```tsx
723
+ const { column } = createColumnHelper<ReceiptLine>();
724
+ const [lines, setLines] = useState<ReceiptLine[]>(initialLines);
725
+
726
+ const columns = [
727
+ column({ id: "sku", label: "SKU", type: "text" }),
728
+ column({
729
+ id: "received",
730
+ label: "Received",
731
+ type: "number",
732
+ typeOptions: { maxDecimals: 0 }, // Display precision, not an input rule.
733
+ edit: {
734
+ canEdit: (_row, { selected }) => selected,
735
+ onValueCommitted: (row, value) => {
736
+ if (value !== null && (!Number.isInteger(value) || value < 0 || value > row.ordered)) {
737
+ throw new CellEditError(`Enter a whole number from 0 to ${row.ordered}`);
738
+ }
739
+ setLines((current) =>
740
+ current.map((line) => (line.id === row.id ? { ...line, received: value } : line)),
741
+ );
742
+ },
743
+ },
744
+ }),
745
+ ];
746
+
747
+ const table = useDataTable({ columns, data: { rows: lines }, rowSelection: {} });
748
+ ```
749
+
750
+ `ReceiptLine` and `initialLines` are the page's row type and initial data. `useState` comes from React; `CellEditError`, `createColumnHelper`, and `useDataTable` come from AppShell.
751
+
752
+ ### Editing options
753
+
754
+ | Option | Column types | Contract |
755
+ | ------------------ | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
756
+ | `onValueChange` | all typed columns | Optional: `(row, draft) => void`. Observes changed drafts, including restored drafts on cancellation. See the draft types above. Updating `data` here is outside the supported coordination contract. Successful return values are ignored. A failure retains the draft and prevents confirmation; a pending returned Promise must succeed before `onValueCommitted` runs. |
757
+ | `onValueCommitted` | all typed columns | Required: `(row, value) => void`. Attempts confirmation of a changed editor output, compared with current consumer-provided `data`, not an edit-start snapshot. Empty or explicitly cleared cells output `null`; equal values do not notify. Successful completion ends the edit; a returned Promise delays edit completion until it settles. Throwing or rejecting retains the draft and error for correction or retry. Adoption and persistence remain consumer-owned; successful return values are ignored. |
758
+ | `error` | all typed columns | Optional: `false \| { render: (ctx: { row: TRow; error: unknown }) => ReactNode }`. Omit for the standard callback-error popover, pass `false` to disable interception, or provide `render` to customize the popover's body. See [Callback error popovers](#callback-error-popovers). |
759
+ | `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. |
760
+ | `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`. |
761
+
762
+ 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`.
763
+
764
+ ### Confirmation and keyboard behavior
765
+
766
+ - **Enter / Shift+Enter:** attempt confirmation in the same logical cell. Success ends editing and restores normal cell display; a pending callback keeps the draft visible, and failure keeps it editable with its error. Enter can retry a failed commit. A failed change check or numeric parse must be corrected before confirmation can proceed. Enter or clicking a focused display cell starts editing again.
767
+ - **Tab / Shift+Tab:** attempt confirmation and move to the next / previous editable cell, skipping read-only cells. Callback failures and pending callbacks do not trap focus: their drafts remain in the original cell. Direct inputs with an already failed commit only move focus, retaining their draft and error; press Enter to retry, or change the input before confirming again. At the ends, focus leaves the table normally. Malformed numeric input retains its existing Tab-blocking behavior.
768
+ - **Esc:** cancel the edit and atomically discard its draft and error, returning to the latest `data`. Notify `onValueChange` only if the restored draft differs, without `onValueCommitted`; restoration failures cannot recreate an error after cancellation. Pending callback results are invalidated, not cancelled. If the error popover is open, the first Esc only closes those details; a subsequent Esc in the editor or error trigger cancels the edit.
769
+ - **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 attempts confirmation of the candidate in the same cell; Tab attempts confirmation of 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 also attempts confirmation. Outside click, blur, and Esc discard an ordinary unconfirmed search and restore the latest `data`, even for an exactly typed label or keyboard-highlighted candidate, without either callback. A selected candidate whose confirmation is pending or failed remains displayed even if the dropdown closes; Esc cancels it. Search/highlight changes emit neither callback; a different selection emits `onValueChange`, then invokes `onValueCommitted` only after the change check succeeds.
770
+ - **Dates:** selecting a day emits its draft and attempts confirmation immediately. Clear explicitly attempts confirmation of `null`.
771
+ - **Date-times:** day and time are independent. Both must be valid to enable Done; an empty time does not become midnight. Done attempts confirmation of the timestamp in the AppShell timezone. Outside closing cancels an ordinary unfinished edit, but retains a pending or failed draft; Esc cancels either. Clear explicitly attempts confirmation of `null`. While a date editor has an active, pending, or failed draft, its trigger shows that raw draft date/time rather than the adopted value's formatter or custom `column.render`.
772
+ - **Blur:** attempts confirmation of a changed direct-input value. It does not retry an already failed commit or duplicate an in-flight confirmation; failed and pending drafts survive leaving and re-entering the cell. Enter during IME composition confirms the conversion, not the cell. Composition changes may notify raw drafts; normalization waits until cell confirmation.
773
+
774
+ A date-time draft preserves missing parts rather than inventing a timestamp:
775
+
776
+ ```ts
777
+ type DateTimeDraft = {
778
+ day: string | null; // "YYYY-MM-DD", or an unselected day.
779
+ time: string | null; // "HH:mm", or an empty time field.
780
+ };
781
+ ```
782
+
783
+ `onValueChange` receives the complete snapshot whenever either part changes, not just the changed part.
784
+
785
+ 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.
786
+
787
+ 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.
788
+
789
+ ### Callback error popovers
790
+
791
+ By default, DataTable catches synchronous throws and rejections of Promises returned by `onValueChange` or `onValueCommitted`. The affected cell receives an error outline and an error icon, and each new failure automatically opens its popover without moving focus — even if an asynchronous failure arrives after you have moved to another cell. The close button dismisses only the details, retaining the draft and error; the same failure does not reopen merely on rerender or refocus. Tab/Shift+Tab to another editor or out of the table also dismisses the cell's details, including a synchronous failure from that confirmation, without clearing the draft or error. Tab within interactive error content does not dismiss it. Click the icon, or press **Alt+ArrowDown** while focused in the cell, to reopen the details. Alt+ArrowDown also moves focus into details that are already open. The error state is exposed through `aria-invalid` and an associated description. Opening or using the error popover is not an ordinary editor blur: it does not confirm or discard the draft merely to inspect the error.
792
+
793
+ No extra configuration is needed for the standard UI. It displays a non-empty message only for `CellEditError`, a native `Error` subclass exported from the package root. Ordinary errors, other thrown values, and empty `CellEditError` messages display the localized message “Could not confirm this change” in both the popover and its accessible description. It never serializes arbitrary thrown values or displays a stack trace. Use `CellEditError` only for messages that are safe to show to users. Translate in consumer code, for example `throw new CellEditError(t("quantityRequired"))`; DataTable displays that message as provided and never treats it as a translation key. The standard popover adds no reset or rollback action. Consumer-owned actions can be included in `error.render` when appropriate.
794
+
795
+ ```tsx
796
+ edit: {
797
+ onValueCommitted: async (row, value) => {
798
+ if (value === null) throw new CellEditError("Enter a quantity");
799
+ await saveReceived(row.id, value);
800
+ setLines((current) =>
801
+ current.map((line) => (line.id === row.id ? { ...line, received: value } : line)),
802
+ );
803
+ },
804
+ }
805
+ ```
806
+
807
+ `saveReceived` is the screen's persistence function. DataTable keeps the raw draft while the returned Promise is pending. Successful completion clears that draft and displays the latest consumer-provided `data`; rejection retains the same draft with its error for correction, an explicit Enter retry, or cancellation. Only successful confirmation ends the edit. Repeated Enter or a subsequent blur does not invoke an in-flight confirmation again. This delays **edit completion**, not focus navigation, and does not add a save mode, automatic retry, optimistic row state, or automatic persistence.
808
+
809
+ To customize the body, provide `error.render`. Its `error` is the original thrown value, unchanged and typed as `unknown`, because JavaScript can throw any value; `row` is the cell's current row. Custom content owns its message-safety policy; narrow to `CellEditError` before displaying its message to follow the standard UI's policy. Popover placement, open/close behavior, and the error indicator remain owned by DataTable. The body may include consumer-owned links or buttons; DataTable adds only the close button alongside it.
810
+
811
+ ```tsx
812
+ error: {
813
+ render: ({ row, error }) => (
814
+ <div>
815
+ <p>Could not update row {row.id}</p>
816
+ <p>{error instanceof CellEditError ? error.message : "Please try again later"}</p>
817
+ </div>
818
+ ),
819
+ }
820
+ ```
821
+
822
+ The renderer is called only when a callback failure exists. Returning `null` hides its content, not the captured error state. This API has no presentation `type`, inline rendering, or `cellContent` context.
823
+
824
+ Pass `error: false` when the screen owns error handling entirely:
825
+
826
+ ```tsx
827
+ edit: {
828
+ onValueCommitted: handleCommitWithOwnFeedback,
829
+ error: false,
830
+ }
831
+ ```
832
+
833
+ With `false`, DataTable preserves eager edit completion: it clears the draft before invoking the confirmation callback, does not intercept synchronous exceptions, and does not observe returned Promises. Handle failures in the callback rather than relying on a hidden error state. Neither `error: true` nor `error: {}` is needed or accepted. Switching between `error: false` and handled mode remounts the editor, discarding its local session and invalidating old results.
834
+
835
+ The cell's editing session is either idle, editing a draft, confirming that draft, or retaining a failed draft with its error. **An error never exists without its target draft.** New input clears the previous failure and invalidates older asynchronous results. A failed `onValueChange` prevents `onValueCommitted` from running; a pending change check must succeed first. A failed commit can be retried without losing its draft. Esc atomically discards the draft and error, restores the latest data, and invalidates pending results. Unmount discards the whole session; it is not persisted across page changes. Existing malformed-number feedback keeps its existing Enter/Tab and blur behavior.
836
+
837
+ Only the returned Promise is observable. A detached operation such as `void saveReceived(...)` is outside this lifecycle; return or await the operation from the callback. The callback types remain `=> void` for compatibility with existing handlers, including async functions; successful return values are ignored. A stale completion cannot clear a newer draft, replace its error, or finish its editor. This protection does not cancel requests, undo callback side effects, roll back optimistic writes, or prevent the consumer from writing stale data, so keep the screen's own request revision or conflict handling when needed.
838
+
839
+ **Callback completion and adoption are distinct.** `onValueCommitted` is the confirmation attempt, not a notification that persistence has already succeeded. A normal return or fulfilled Promise completes that attempt; DataTable never adopts the returned value. Returning without adopting a value remains a valid consumer-owned rejection strategy: it completes the edit and returns to current data without a callback error. Throw or reject if the draft should remain available with an error.
840
+
841
+ ### Data ownership and limits
842
+
843
+ The consumer's `data` is the source of truth for adopted values. Observe drafts in `onValueChange` and adopt values in `onValueCommitted`, optionally after your own validation or normalization. A normal callback completion clears the draft and returns to current data, even when you deliberately leave `data` unchanged. Throwing or returning a rejected Promise instead retains the original draft and its error until correction, successful retry, or cancellation. Tab navigation remains available in either case.
844
+
845
+ 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 and callback errors without either callback; if an ordinary blur occurs before unmount, its normal behavior still applies.
846
+
847
+ There are no `edit.min`, `max`, `maxDecimals`, `required`, `validate`, or `schema` options. Required-field handling, validation rules, saving, optimistic updates, rollback, and save indicators belong to the screen. DataTable coordinates the local editing lifecycle around callback success or failure; it does not implement business rules or coordinate persistence. There is no built-in unsaved-changes or leave-page guard.
848
+
849
+ 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.
850
+
851
+ 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 adopted value's display, not the active draft or built-in editor. Multi-value badge editing, a custom-editor option within `edit`, multi-cell paste, fill-down, and undo history are not provided.
852
+
853
+ ### Consumer-owned saving patterns
854
+
855
+ 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.
856
+
857
+ Both examples use these imports:
858
+
859
+ ```tsx
860
+ import { useRef, useState } from "react";
861
+ import { Button, DataTable, createColumnHelper, useDataTable } from "@tailor-platform/app-shell";
862
+ ```
863
+
864
+ Their simulated persistence rejects quantity `13` so error handling can be tried without a backend. Replace it with your application's API.
865
+
866
+ #### Immediate saving
867
+
868
+ 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. This pattern handles its own feedback rather than throwing to the cell popover. Saved-value tracking and in-flight status remain consumer-owned.
869
+
870
+ 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.
871
+
872
+ <example-preview name="immediate-saving"></example-preview>
873
+
874
+ ```tsx
875
+ function ImmediateSaving() {
876
+ type Line = { id: string; item: string; ordered: number; received: number | null };
877
+ const initialRows: Line[] = [
878
+ { id: "1", item: "Small carton", ordered: 24, received: 12 },
879
+ { id: "2", item: "Large carton", ordered: 36, received: null },
880
+ ];
881
+ const [rows, setRows] = useState(initialRows);
882
+ const [error, setError] = useState<string | null>(null);
883
+ const [savingCount, setSavingCount] = useState(0);
884
+ const saves = useRef(
885
+ new Map(
886
+ initialRows.map((row) => [
887
+ row.id,
888
+ {
889
+ revision: 0,
890
+ savedRevision: 0,
891
+ savedValue: row.received,
892
+ },
893
+ ]),
894
+ ),
895
+ );
896
+ const { column } = createColumnHelper<Line>();
897
+ const update = (id: string, received: number | null) =>
898
+ setRows((current) => current.map((row) => (row.id === id ? { ...row, received } : row)));
899
+
900
+ async function persistField(id: string, received: number | null) {
901
+ // Demo API: replace with a request that patches only this row's received field.
902
+ await new Promise((resolve) => setTimeout(resolve, 400));
903
+ if (received === 13) throw new Error(`Could not save row ${id}; quantity 13 was rejected`);
904
+ }
905
+
906
+ const table = useDataTable({
907
+ data: { rows },
908
+ columns: [
909
+ column({ id: "item", label: "Item", type: "text" }),
910
+ column({ id: "ordered", label: "Ordered", type: "number" }),
911
+ column({
912
+ id: "received",
913
+ label: "Received",
914
+ type: "number",
915
+ edit: {
916
+ onValueCommitted: (row, value) => {
917
+ if (value !== null && (!Number.isInteger(value) || value < 0 || value > row.ordered)) {
918
+ setError(`Enter a whole number from 0 to ${row.ordered}, or clear the cell`);
919
+ return;
920
+ }
921
+ const request = saves.current.get(row.id)!;
922
+ const revision = ++request.revision;
923
+ setError(null);
924
+ update(row.id, value);
925
+ setSavingCount((count) => count + 1);
926
+ void persistField(row.id, value)
927
+ .then(() => {
928
+ if (revision > request.savedRevision) {
929
+ request.savedRevision = revision;
930
+ request.savedValue = value;
931
+ }
932
+ })
933
+ .catch((cause: unknown) => {
934
+ if (revision !== request.revision) return;
935
+ update(row.id, request.savedValue);
936
+ setError(cause instanceof Error ? cause.message : "Save failed");
937
+ })
938
+ .finally(() => setSavingCount((count) => count - 1));
939
+ },
940
+ },
941
+ }),
942
+ ],
943
+ });
944
+
945
+ return (
946
+ <div className="space-y-3">
947
+ <p role="status">
948
+ {savingCount ? `${savingCount} field save(s) pending` : "No saves pending"}
949
+ </p>
950
+ {error && <p role="alert">{error}</p>}
951
+ <DataTable.Root value={table}>
952
+ <DataTable.Table />
953
+ </DataTable.Root>
954
+ </div>
955
+ );
956
+ }
957
+ ```
958
+
959
+ #### Collect then submit
960
+
961
+ 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.
962
+
963
+ This batch collects **confirmed/adopted row values**, not unfinished, pending, or failed input inside editors. Save does not automatically gather drafts, wait for pending cell callbacks, or validate the table. An ordinary blur may confirm a valid direct input before a button click; malformed numeric blur discards that input. Clicking outside a dropdown or date-time picker cancels an ordinary unfinished search/edit, but a pending or failed candidate remains in its cell and is not part of the batch. Tell users to resolve and successfully confirm edits first; if your workflow needs to submit all unfinished inputs as one coordinated form, use the `render` escape hatch or a dedicated form instead.
964
+
965
+ <example-preview name="collect-then-submit"></example-preview>
966
+
967
+ ```tsx
968
+ function CollectThenSubmit() {
969
+ type Line = { id: string; item: string; ordered: number; received: number | null };
970
+ const initialRows: Line[] = [
971
+ { id: "1", item: "Small carton", ordered: 24, received: 12 },
972
+ { id: "2", item: "Large carton", ordered: 36, received: null },
973
+ ];
974
+ const [savedRows, setSavedRows] = useState(initialRows);
975
+ const [rows, setRows] = useState(initialRows);
976
+ const [saving, setSaving] = useState(false);
977
+ const [error, setError] = useState<string | null>(null);
978
+ const { column } = createColumnHelper<Line>();
979
+
980
+ async function save() {
981
+ const invalid = rows.find(
982
+ (row) =>
983
+ row.received !== null &&
984
+ (!Number.isInteger(row.received) || row.received < 0 || row.received > row.ordered),
985
+ );
986
+ if (invalid) {
987
+ setError(`${invalid.item}: enter a whole number from 0 to ${invalid.ordered}, or clear it`);
988
+ return;
989
+ }
990
+ // This snapshot contains adopted confirmations, not unfinished editor input.
991
+ const confirmedRows = rows;
992
+ setSaving(true);
993
+ setError(null);
994
+ try {
995
+ // Demo API: replace with a batch request for confirmedRows.
996
+ await new Promise((resolve) => setTimeout(resolve, 400));
997
+ if (confirmedRows.some((row) => row.received === 13)) {
998
+ throw new Error("Batch rejected quantity 13; correct it and try again");
999
+ }
1000
+ setSavedRows(confirmedRows);
1001
+ } catch (cause) {
1002
+ setError(cause instanceof Error ? cause.message : "Save failed");
1003
+ } finally {
1004
+ setSaving(false);
1005
+ }
1006
+ }
1007
+
1008
+ const table = useDataTable({
1009
+ data: { rows },
1010
+ columns: [
1011
+ column({ id: "item", label: "Item", type: "text" }),
1012
+ column({ id: "ordered", label: "Ordered", type: "number" }),
1013
+ column({
1014
+ id: "received",
1015
+ label: "Received",
1016
+ type: "number",
1017
+ edit: {
1018
+ canEdit: () => !saving,
1019
+ onValueCommitted: (row, value) =>
1020
+ setRows((current) =>
1021
+ current.map((line) => (line.id === row.id ? { ...line, received: value } : line)),
1022
+ ),
1023
+ },
1024
+ }),
1025
+ ],
1026
+ });
1027
+
1028
+ return (
1029
+ <div className="space-y-3">
1030
+ <p role="status">{saving ? "Saving batch…" : "Confirm edits, then Save or Cancel"}</p>
1031
+ {error && <p role="alert">{error}</p>}
1032
+ <DataTable.Root value={table}>
1033
+ <DataTable.Table />
1034
+ </DataTable.Root>
1035
+ <div className="flex gap-2">
1036
+ <Button disabled={saving} onClick={() => void save()}>
1037
+ Save
1038
+ </Button>
1039
+ <Button
1040
+ disabled={saving}
1041
+ variant="outline"
1042
+ onClick={() => {
1043
+ setRows(savedRows);
1044
+ setError(null);
1045
+ }}
1046
+ >
1047
+ Cancel
1048
+ </Button>
1049
+ </div>
1050
+ </div>
1051
+ );
1052
+ }
1053
+ ```
1054
+
374
1055
  ## Row selection
375
1056
 
376
1057
  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.
@@ -521,35 +1202,39 @@ const table = useDataTable({
521
1202
 
522
1203
  ## `Column`
523
1204
 
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.
1205
+ 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.
525
1206
 
526
1207
  ### Shared fields
527
1208
 
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. |
1209
+ | Property | Type | Description |
1210
+ | ------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1211
+ | `label` | `string` | Column header text. Omit for icon-only columns. |
1212
+ | `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.). |
1213
+ | `footer` | `string \| ((ctx: FooterRenderContext<TRow>) => ReactNode)` | Optional static string or column footer renderer, including callbacks created by `aggregations`. Renderers receive current `data.rows` (the current page, not selected rows or all records). Enables the automatic sticky `<tfoot>` row; no default content or typed formatting is applied. See [Column footers](#column-footers). |
1214
+ | `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`). |
1215
+ | `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. |
1216
+ | `headerProps` | `(ctx: HeaderRenderContext) => Pick<ComponentProps<"th">, "className" \| "style" \| "title"> \| undefined` | Sets classes, inline styles, and a native title on this column's `<th>` based on the header context. Return `undefined` to keep the defaults. Does not replace header content or sorting. Column width and sticky positioning take precedence over inline styles. See [Header styling](#header-styling). |
1217
+ | `cellProps` | `(row: TRow) => Pick<ComponentProps<"td">, "className" \| "style" \| "title"> \| undefined` | Sets classes, inline styles, and a native title on the data-cell `<td>`. Column width and sticky positioning take precedence over inline styles. See [Per-cell styling](#per-cell-styling). |
1218
+ | `footerProps` | `(ctx: FooterRenderContext<TRow>) => Pick<ComponentProps<"td">, "className" \| "style" \| "title"> \| undefined` | Sets classes, inline styles, and a native title on this column's footer `<td>` based on current rows. Return `undefined` to keep the defaults. Does not create a footer row or replace its content. Column width and sticky positioning take precedence. See [Footer styling](#footer-styling). |
1219
+ | `width` | `number` | Fixed column width in pixels. Optional. |
1220
+ | `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. |
1221
+ | `align` | `"left" \| "right"` | Horizontal alignment. Defaults to `"right"` for `type: "number"` and `type: "money"`; `"left"` otherwise. Pass `"left"` to opt a numeric column out. |
1222
+ | `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). |
1223
+ | `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. |
1224
+ | `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. |
1225
+ | `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
1226
 
542
1227
  ### `type`-specific fields
543
1228
 
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`) |
1229
+ | `type` | `typeOptions` | Optional `edit` |
1230
+ | ----------- | ------------------------------------------------------------ | ----------------------------- |
1231
+ | _(omitted)_ | _(not allowed; provide `render` to draw the cell)_ | _(not allowed)_ |
1232
+ | `"text"` | _(not allowed)_ | `TextCellEditOptions<TRow>` |
1233
+ | `"number"` | `NumberCellOptions` | `NumberCellEditOptions<TRow>` |
1234
+ | `"money"` | `MoneyCellOptions<TRow>` | `NumberCellEditOptions<TRow>` |
1235
+ | `"date"` | `DateCellOptions` | `DateCellEditOptions<TRow>` |
1236
+ | `"badge"` | `BadgeCellOptions` | `BadgeCellEditOptions<TRow>` |
1237
+ | `"link"` | **Required** — `LinkCellOptions<TRow>` (must include `href`) | `TextCellEditOptions<TRow>` |
553
1238
 
554
1239
  ## Cell types
555
1240
 
@@ -967,7 +1652,7 @@ useDataTable<Order>({
967
1652
 
968
1653
  ## `createColumnHelper`
969
1654
 
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.
1655
+ 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. Selector-based `aggregations` are imported separately; inline footer selectors infer their row type from the column definition.
971
1656
 
972
1657
  ```tsx
973
1658
  const { column, inferColumns } = createColumnHelper<Order>();
@@ -1017,10 +1702,12 @@ const { variables, control } = useCollectionVariables({
1017
1702
  });
1018
1703
 
1019
1704
  // variables.pagination → { first, after? } or { last, before? }
1020
- // variables.query → filter input object or undefined
1705
+ // variables.query → filter input object (undefined when no filters are active)
1021
1706
  // variables.order → sort input array or undefined
1022
1707
  ```
1023
1708
 
1709
+ When you need an object for `Object.keys` or destructuring, use `variables.query ?? {}`.
1710
+
1024
1711
  ### Options
1025
1712
 
1026
1713
  | Option | Type | Description |
@@ -1052,7 +1739,7 @@ const { variables, control } = useCollectionVariables({
1052
1739
  params: { pageSize: 20 },
1053
1740
  });
1054
1741
 
1055
- // variables.query is now BuildQueryVariables<typeof tableMetadata.order>
1742
+ // variables.query is now BuildQueryVariables<typeof tableMetadata.order> | undefined
1056
1743
  // variables.order is now { field: OrderableFieldName; direction: "Asc" | "Desc" }[]
1057
1744
  const [result] = useQuery({
1058
1745
  query: LIST_ORDERS, // TypedDocumentNode — variables are fully type-checked
@@ -1164,5 +1851,6 @@ const columns = [
1164
1851
 
1165
1852
  ## Related
1166
1853
 
1854
+ - [aggregations](../api/aggregations.md) — Selector-based aggregation helpers for column footers
1167
1855
  - [CsvImporter](./csv-importer.md) — Guided CSV import flow
1168
1856
  - [Table](./table.md) — Low-level table primitives used internally by DataTable