@multiplatform.one/table 7.8.0 → 7.11.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 (110) hide show
  1. package/README.md +60 -0
  2. package/dist/cjs/components/DataTable/DataTableCardControls.cjs +90 -54
  3. package/dist/cjs/components/DataTable/DataTableCardControls.native.js +93 -56
  4. package/dist/cjs/components/DataTable/DataTableCardControls.native.js.map +1 -1
  5. package/dist/cjs/components/DataTable/DataTableMobileCards.cjs +100 -33
  6. package/dist/cjs/components/DataTable/DataTableMobileCards.native.js +139 -38
  7. package/dist/cjs/components/DataTable/DataTableMobileCards.native.js.map +1 -1
  8. package/dist/cjs/components/DataTable/DataTableRoot.cjs +51 -8
  9. package/dist/cjs/components/DataTable/DataTableRoot.native.js +53 -9
  10. package/dist/cjs/components/DataTable/DataTableRoot.native.js.map +1 -1
  11. package/dist/cjs/components/DataTable/index.native.js +59 -93
  12. package/dist/cjs/components/DataTable/index.native.js.map +1 -1
  13. package/dist/cjs/components/DataTableFilterButton/index.cjs +14 -0
  14. package/dist/cjs/components/DataTableFilterButton/index.native.js +15 -0
  15. package/dist/cjs/components/DataTableFilterButton/index.native.js.map +1 -1
  16. package/dist/cjs/components/DataTableToolbar/index.cjs +178 -101
  17. package/dist/cjs/components/DataTableToolbar/index.native.js +187 -109
  18. package/dist/cjs/components/DataTableToolbar/index.native.js.map +1 -1
  19. package/dist/cjs/hooks/useDataTable.cjs +32 -1
  20. package/dist/cjs/hooks/useDataTable.native.js +37 -1
  21. package/dist/cjs/hooks/useDataTable.native.js.map +1 -1
  22. package/dist/esm/components/DataTable/DataTableCardControls.mjs +89 -54
  23. package/dist/esm/components/DataTable/DataTableCardControls.mjs.map +1 -1
  24. package/dist/esm/components/DataTable/DataTableCardControls.native.js +92 -56
  25. package/dist/esm/components/DataTable/DataTableCardControls.native.js.map +1 -1
  26. package/dist/esm/components/DataTable/DataTableMobileCards.mjs +99 -34
  27. package/dist/esm/components/DataTable/DataTableMobileCards.mjs.map +1 -1
  28. package/dist/esm/components/DataTable/DataTableMobileCards.native.js +138 -39
  29. package/dist/esm/components/DataTable/DataTableMobileCards.native.js.map +1 -1
  30. package/dist/esm/components/DataTable/DataTableRoot.mjs +53 -10
  31. package/dist/esm/components/DataTable/DataTableRoot.mjs.map +1 -1
  32. package/dist/esm/components/DataTable/DataTableRoot.native.js +55 -11
  33. package/dist/esm/components/DataTable/DataTableRoot.native.js.map +1 -1
  34. package/dist/esm/components/DataTable/index.native.js +60 -94
  35. package/dist/esm/components/DataTable/index.native.js.map +1 -1
  36. package/dist/esm/components/DataTableFilterButton/index.mjs +14 -1
  37. package/dist/esm/components/DataTableFilterButton/index.mjs.map +1 -1
  38. package/dist/esm/components/DataTableFilterButton/index.native.js +15 -1
  39. package/dist/esm/components/DataTableFilterButton/index.native.js.map +1 -1
  40. package/dist/esm/components/DataTableToolbar/index.mjs +180 -104
  41. package/dist/esm/components/DataTableToolbar/index.mjs.map +1 -1
  42. package/dist/esm/components/DataTableToolbar/index.native.js +189 -112
  43. package/dist/esm/components/DataTableToolbar/index.native.js.map +1 -1
  44. package/dist/esm/hooks/useDataTable.mjs +32 -1
  45. package/dist/esm/hooks/useDataTable.mjs.map +1 -1
  46. package/dist/esm/hooks/useDataTable.native.js +37 -1
  47. package/dist/esm/hooks/useDataTable.native.js.map +1 -1
  48. package/dist/jsx/components/DataTable/DataTableCardControls.mjs +89 -54
  49. package/dist/jsx/components/DataTable/DataTableCardControls.mjs.map +1 -1
  50. package/dist/jsx/components/DataTable/DataTableCardControls.native.js +93 -56
  51. package/dist/jsx/components/DataTable/DataTableCardControls.native.js.map +1 -1
  52. package/dist/jsx/components/DataTable/DataTableMobileCards.mjs +99 -34
  53. package/dist/jsx/components/DataTable/DataTableMobileCards.mjs.map +1 -1
  54. package/dist/jsx/components/DataTable/DataTableMobileCards.native.js +139 -38
  55. package/dist/jsx/components/DataTable/DataTableMobileCards.native.js.map +1 -1
  56. package/dist/jsx/components/DataTable/DataTableRoot.mjs +53 -10
  57. package/dist/jsx/components/DataTable/DataTableRoot.mjs.map +1 -1
  58. package/dist/jsx/components/DataTable/DataTableRoot.native.js +53 -9
  59. package/dist/jsx/components/DataTable/DataTableRoot.native.js.map +1 -1
  60. package/dist/jsx/components/DataTable/index.native.js +59 -93
  61. package/dist/jsx/components/DataTable/index.native.js.map +1 -1
  62. package/dist/jsx/components/DataTableFilterButton/index.mjs +14 -1
  63. package/dist/jsx/components/DataTableFilterButton/index.mjs.map +1 -1
  64. package/dist/jsx/components/DataTableFilterButton/index.native.js +15 -0
  65. package/dist/jsx/components/DataTableFilterButton/index.native.js.map +1 -1
  66. package/dist/jsx/components/DataTableToolbar/index.mjs +180 -104
  67. package/dist/jsx/components/DataTableToolbar/index.mjs.map +1 -1
  68. package/dist/jsx/components/DataTableToolbar/index.native.js +187 -109
  69. package/dist/jsx/components/DataTableToolbar/index.native.js.map +1 -1
  70. package/dist/jsx/hooks/useDataTable.mjs +32 -1
  71. package/dist/jsx/hooks/useDataTable.mjs.map +1 -1
  72. package/dist/jsx/hooks/useDataTable.native.js +37 -1
  73. package/dist/jsx/hooks/useDataTable.native.js.map +1 -1
  74. package/package.json +14 -14
  75. package/src/components/DataTable/DataTable.spec.tsx +5 -3
  76. package/src/components/DataTable/DataTable.stories.tsx +59 -0
  77. package/src/components/DataTable/DataTableCardControls.stories.tsx +61 -2
  78. package/src/components/DataTable/DataTableCardControls.tsx +92 -66
  79. package/src/components/DataTable/DataTableMobileCards.tsx +162 -39
  80. package/src/components/DataTable/DataTableRoot.tsx +82 -14
  81. package/src/components/DataTable/cardLayout.spec.tsx +23 -11
  82. package/src/components/DataTable/cardToolbar.spec.tsx +238 -0
  83. package/src/components/DataTable/chromeInterplay.spec.tsx +7 -5
  84. package/src/components/DataTable/columnWidthLayout.spec.tsx +189 -0
  85. package/src/components/DataTable/dataTableCardControls.spec.tsx +3 -2
  86. package/src/components/DataTable/dataTableMobileCards.spec.tsx +50 -2
  87. package/src/components/DataTable/footerLayout.spec.tsx +250 -0
  88. package/src/components/DataTable/index.native.tsx +80 -124
  89. package/src/components/DataTable/nativeCardParity.spec.tsx +95 -0
  90. package/src/components/DataTable/pinningGeometry.spec.tsx +66 -1
  91. package/src/components/DataTableExport/DataTableExport.spec.tsx +42 -18
  92. package/src/components/DataTableFilterButton/index.tsx +9 -0
  93. package/src/components/DataTableToolbar/index.tsx +184 -105
  94. package/src/hooks/useDataTable.spec.ts +114 -0
  95. package/src/hooks/useDataTable.ts +23 -1
  96. package/src/types.ts +14 -12
  97. package/types/components/DataTable/DataTableCardControls.d.ts +3 -0
  98. package/types/components/DataTable/DataTableCardControls.d.ts.map +1 -1
  99. package/types/components/DataTable/DataTableMobileCards.d.ts +42 -8
  100. package/types/components/DataTable/DataTableMobileCards.d.ts.map +1 -1
  101. package/types/components/DataTable/DataTableRoot.d.ts.map +1 -1
  102. package/types/components/DataTable/index.native.d.ts +1 -1
  103. package/types/components/DataTable/index.native.d.ts.map +1 -1
  104. package/types/components/DataTableFilterButton/index.d.ts +2 -1
  105. package/types/components/DataTableFilterButton/index.d.ts.map +1 -1
  106. package/types/components/DataTableToolbar/index.d.ts +1 -0
  107. package/types/components/DataTableToolbar/index.d.ts.map +1 -1
  108. package/types/hooks/useDataTable.d.ts.map +1 -1
  109. package/types/types.d.ts +14 -12
  110. package/types/types.d.ts.map +1 -1
@@ -5,14 +5,18 @@
5
5
  * resolved layout is "cards" (below the `$sm` breakpoint with
6
6
  * layout="auto", or forced via layout="cards").
7
7
  *
8
- * Card anatomy (knobs system): `surface` + `cardSurface` fragments own
9
- * the card frame; the first visible column renders as the card title in
10
- * the header row (with selection checkbox / row number / delete), and
11
- * the remaining visible columns render as label/value rows. Column
12
- * visibility and order from table state apply unchanged — cells come
13
- * from `row.getVisibleCells()`. Values render through the same cell
14
- * renderers as the grid, inside `TableCellContext` so fields stay
15
- * chromeless (useIsInTableCell).
8
+ * Card anatomy: the first visible column is the card title in the header
9
+ * row (with selection checkbox / row number / delete). The remaining
10
+ * visible columns are NOT a stack of label/value rows any more — see
11
+ * `classifyCardField`. Column visibility and order from table state apply
12
+ * unchanged — cells come from `row.getVisibleCells()`. Values render
13
+ * through the same cell renderers as the grid, inside `TableCellContext`
14
+ * so fields stay chromeless (useIsInTableCell).
15
+ *
16
+ * The card is FLAT: no border, no elevation. A card list is drawn inside
17
+ * a panel that already has its own frame, and an outlined, shadowed card
18
+ * inside it reads as a box inside a box. The `cardSurface` fill and the
19
+ * gap between cards are what separate one row from the next.
16
20
  */
17
21
 
18
22
  import { TrashIcon } from "@phosphor-icons/react";
@@ -21,10 +25,10 @@ import { Button, Checkbox } from "@multiplatform.one/forms";
21
25
  import { hairline, useResolvedKnobs } from "@multiplatform.one/theme";
22
26
  import type React from "react";
23
27
  import { useCallback } from "react";
24
- import { Text, View, XStack } from "tamagui";
28
+ import { Text, View, VisuallyHidden, XStack } from "tamagui";
25
29
  import { useTranslation } from "react-i18next";
26
30
  import { TableCellContext } from "@multiplatform.one/table-primitives";
27
- import type { DataTableColumn } from "../../types";
31
+ import type { DataTableColumn, DataTableField } from "../../types";
28
32
  import { withInterp } from "../../utils/withInterp";
29
33
  import { rowIntentProps } from "./dataTableChrome";
30
34
 
@@ -34,13 +38,74 @@ import { rowIntentProps } from "./dataTableChrome";
34
38
  * header is a render function or a bare glyph (the liked heart) — without
35
39
  * it a card field row would print the raw column id (`_liked_by`).
36
40
  */
37
- function getColumnLabel(cell: any): string {
41
+ export function getColumnLabel(cell: any): string {
38
42
  const columnDef = cell.column.columnDef as DataTableColumn;
39
43
  const metaLabel = columnDef.meta?.label;
40
44
  if (typeof metaLabel === "string" && metaLabel) return metaLabel;
41
45
  return typeof columnDef.header === "string" ? columnDef.header : columnDef.id || cell.column.id;
42
46
  }
43
47
 
48
+ /** Fields that draw their own control, which names itself and needs a target. */
49
+ const selfNamingFields = new Set<DataTableField>([
50
+ "checkbox",
51
+ "switch",
52
+ "rating",
53
+ "progress",
54
+ "image",
55
+ "signature",
56
+ "fileupload",
57
+ "colorpicker",
58
+ "barcode",
59
+ "geolocation",
60
+ ]);
61
+
62
+ /** Column ids whose value is a state badge. A badge names itself. */
63
+ const indicatorIds = new Set(["status", "priority", "workflow_state", "state"]);
64
+
65
+ const dateLike = /^\d{4}-\d{2}-\d{2}([T ]|$)|^\d{1,2}\/\d{1,2}\/\d{2,4}$/;
66
+ const numberLike = /^[+\-−]?[\d,\s]*\d(\.\d+)?%?$/;
67
+
68
+ export type CardFieldKind = "skip" | "control" | "bare" | "figure";
69
+
70
+ /**
71
+ * Whether a card field prints the column's name, and where it goes.
72
+ *
73
+ * A card is not the grid with its header repeated down the page. In the
74
+ * grid the header is written ONCE for a whole column of values; on a card
75
+ * it is written once per value, so a 12-column table prints 12 labels per
76
+ * record. That is what a label costs here, and most labels do not earn it.
77
+ *
78
+ * skip the raw value is absent. A field with nothing in it is not a
79
+ * fact — "Rejected —" spends a row saying nothing.
80
+ * control a button, a switch, a rating. The control names itself and
81
+ * needs its own tap target, so it keeps a row and takes no label.
82
+ * bare a string that is neither a number nor a date. "DIVIDEND
83
+ * CREDIT" and "Income:Dividends" say what they are; "Payee" and
84
+ * "Account" in front of them are the header printed twice. The
85
+ * name is kept for assistive tech via VisuallyHidden, which
86
+ * costs no layout.
87
+ * figure a number, a date, a boolean. "1,204" alone cannot be read, so
88
+ * these DO keep their label — inline beside the figure, not in a
89
+ * 40%-wide column of its own.
90
+ */
91
+ export function classifyCardField(cell: any): CardFieldKind {
92
+ const def = cell.column.columnDef as DataTableColumn;
93
+ const id = String(cell.column.id ?? "");
94
+ const role = def.meta?.role;
95
+ if (role === "hidden" || role === "skip") return "skip";
96
+ if (role === "actions" || id.startsWith("_") || (def.field && selfNamingFields.has(def.field))) {
97
+ return "control";
98
+ }
99
+ const raw = typeof cell.getValue === "function" ? cell.getValue() : undefined;
100
+ if (raw == null || (typeof raw === "string" && raw.trim() === "")) return "skip";
101
+ if (role === "indicator" || indicatorIds.has(id)) return "bare";
102
+ if (typeof raw === "string") {
103
+ const value = raw.trim();
104
+ return numberLike.test(value) || dateLike.test(value) ? "figure" : "bare";
105
+ }
106
+ return "figure";
107
+ }
108
+
44
109
  export interface DataTableMobileCardsProps<TData> {
45
110
  tableRows: any[];
46
111
  enableRowSelection?: boolean;
@@ -98,6 +163,15 @@ export function DataTableMobileCards<TData extends Record<string, any>>({
98
163
  const { knobProps } = useResolvedKnobs({ component: "DataTable", compact });
99
164
  const tableZebraOn = knobProps.tableZebra === "on";
100
165
 
166
+ const renderCell = useCallback(
167
+ (cell: any, index: number, rowData: TData) => (
168
+ <TableCellContext.Provider value={{ inTableCell: true, isHeader: false }}>
169
+ {renderCellContent(cell, index, rowData)}
170
+ </TableCellContext.Provider>
171
+ ),
172
+ [renderCellContent],
173
+ );
174
+
101
175
  const renderMobileCard = useCallback(
102
176
  (row: any, index: number) => {
103
177
  const rowData = row.original as TData;
@@ -105,14 +179,22 @@ export function DataTableMobileCards<TData extends Record<string, any>>({
105
179
  const [titleCell, ...detailCells] = visibleCells;
106
180
  const showDelete = showDeleteRow && !readOnly;
107
181
 
182
+ const bare: any[] = [];
183
+ const figures: any[] = [];
184
+ const controls: any[] = [];
185
+ for (const cell of detailCells) {
186
+ const kind = classifyCardField(cell);
187
+ if (kind === "bare") bare.push(cell);
188
+ else if (kind === "figure") figures.push(cell);
189
+ else if (kind === "control") controls.push(cell);
190
+ }
191
+ const hasBody = bare.length > 0 || figures.length > 0 || controls.length > 0;
192
+
108
193
  return (
109
194
  <View
110
195
  key={`${row.id}-${index}`}
111
196
  testID="data-table-card"
112
- {...knobProps.surface}
113
197
  {...knobProps.cardSurface}
114
- elevation={knobProps.elevation}
115
- borderColor="$color6"
116
198
  {...(tableZebraOn && index % 2 === 1 ? { backgroundColor: "$color2" } : {})}
117
199
  {...({
118
200
  "data-table-zebra": tableZebraOn ? (index % 2 === 0 ? "even" : "odd") : "off",
@@ -124,7 +206,7 @@ export function DataTableMobileCards<TData extends Record<string, any>>({
124
206
  <XStack
125
207
  alignItems="center"
126
208
  gap="$2"
127
- {...(detailCells.length > 0
209
+ {...(hasBody
128
210
  ? {
129
211
  paddingBottom: "$2",
130
212
  marginBottom: "$2",
@@ -156,11 +238,10 @@ export function DataTableMobileCards<TData extends Record<string, any>>({
156
238
  flex={1}
157
239
  minWidth={0}
158
240
  aria-label={getColumnLabel(titleCell)}
241
+ accessibilityLabel={getColumnLabel(titleCell)}
159
242
  {...({ "data-card-slot": "title" } as any)}
160
243
  >
161
- <TableCellContext.Provider value={{ inTableCell: true, isHeader: false }}>
162
- {renderCellContent(titleCell, index, rowData)}
163
- </TableCellContext.Provider>
244
+ {renderCell(titleCell, index, rowData)}
164
245
  </View>
165
246
  ) : (
166
247
  <View flex={1} />
@@ -185,29 +266,71 @@ export function DataTableMobileCards<TData extends Record<string, any>>({
185
266
  )}
186
267
  </XStack>
187
268
 
188
- {/* Field rows: remaining visible columns as label/value pairs */}
189
- {detailCells.map((cell: any) => (
190
- <View
191
- key={cell.id}
192
- flexDirection="row"
269
+ {/* Self-naming values, wrapped into one flow. No printed labels:
270
+ the name goes to assistive tech only, at no layout cost. */}
271
+ {bare.length > 0 ? (
272
+ <XStack
273
+ flexWrap="wrap"
193
274
  alignItems="center"
194
- paddingVertical="$1.5"
195
275
  gap="$2"
276
+ {...({ "data-card-slot": "meta" } as any)}
196
277
  >
197
- <Text
198
- fontSize={knobProps.sizeToken}
199
- color="$color10"
200
- fontWeight="500"
201
- width="40%"
202
- flexShrink={0}
203
- >
204
- {getColumnLabel(cell)}
205
- </Text>
206
- <View flex={1} minWidth={0}>
207
- <TableCellContext.Provider value={{ inTableCell: true, isHeader: false }}>
208
- {renderCellContent(cell, index, rowData)}
209
- </TableCellContext.Provider>
210
- </View>
278
+ {bare.map((cell: any, i: number) => (
279
+ <XStack key={cell.id} alignItems="center" gap="$2" flexShrink={1} minWidth={0}>
280
+ <VisuallyHidden>{`${getColumnLabel(cell)}: `}</VisuallyHidden>
281
+ <View flexShrink={1} minWidth={0}>
282
+ {renderCell(cell, index, rowData)}
283
+ </View>
284
+ {/* The separator TRAILS its value, so a line that wraps
285
+ starts with content instead of a stray dot. */}
286
+ {i < bare.length - 1 ? (
287
+ <Text
288
+ fontSize={knobProps.sizeToken}
289
+ color="$color8"
290
+ aria-hidden
291
+ accessibilityElementsHidden
292
+ importantForAccessibility="no-hide-descendants"
293
+ >
294
+ ·
295
+ </Text>
296
+ ) : null}
297
+ </XStack>
298
+ ))}
299
+ </XStack>
300
+ ) : null}
301
+
302
+ {/* Figures keep their name, inline beside the value. */}
303
+ {figures.length > 0 ? (
304
+ <XStack
305
+ flexWrap="wrap"
306
+ alignItems="center"
307
+ gap="$3"
308
+ paddingTop={bare.length > 0 ? "$1.5" : 0}
309
+ {...({ "data-card-slot": "figure" } as any)}
310
+ >
311
+ {figures.map((cell: any) => (
312
+ <XStack key={cell.id} alignItems="center" gap="$1.5" flexShrink={1} minWidth={0}>
313
+ <Text fontSize={knobProps.sizeToken} color="$color10" fontWeight="500">
314
+ {getColumnLabel(cell)}
315
+ </Text>
316
+ <View flexShrink={1} minWidth={0}>
317
+ {renderCell(cell, index, rowData)}
318
+ </View>
319
+ </XStack>
320
+ ))}
321
+ </XStack>
322
+ ) : null}
323
+
324
+ {/* Controls keep a row of their own — they are tap targets. */}
325
+ {controls.map((cell: any) => (
326
+ <View
327
+ key={cell.id}
328
+ paddingTop="$2"
329
+ minWidth={0}
330
+ {...({ "data-card-slot": "control" } as any)}
331
+ >
332
+ <VisuallyHidden>{`${getColumnLabel(cell)}: `}</VisuallyHidden>
333
+ {renderCell(cell, index, rowData)}
211
334
  </View>
212
335
  ))}
213
336
  </View>
@@ -221,7 +344,7 @@ export function DataTableMobileCards<TData extends Record<string, any>>({
221
344
  disabled,
222
345
  dataLength,
223
346
  minRows,
224
- renderCellContent,
347
+ renderCell,
225
348
  onDeleteRow,
226
349
  compact,
227
350
  knobProps,
@@ -43,7 +43,7 @@ import type {
43
43
  } from "../../types";
44
44
  import { DataTableColumnPinning } from "../DataTableColumnPinning";
45
45
  import { DataTablePagination } from "../DataTablePagination";
46
- import { DataTableToolbar } from "../DataTableToolbar";
46
+ import { DataTableCardToolbar, DataTableToolbar } from "../DataTableToolbar";
47
47
  import { DataTableBulkActions } from "../DataTableBulkActions";
48
48
  import { DataTableCell } from "../DataTableCell";
49
49
  import { ColumnHeaderFilter } from "../ColumnHeaderFilter";
@@ -56,7 +56,7 @@ import type {
56
56
  DataTableStateContextValue,
57
57
  FilterEditTarget,
58
58
  } from "./DataTableStateContext";
59
- import { DataTableCardControls } from "./DataTableCardControls";
59
+ import { DataTableCardControls, DataTableCardSortControl } from "./DataTableCardControls";
60
60
  import { DataTableMobileCards } from "./DataTableMobileCards";
61
61
  import { DataTableDocumentList } from "./DataTableDocumentList";
62
62
  import { DataTableVirtualized } from "./DataTableVirtualized";
@@ -334,6 +334,7 @@ export function DataTableRoot<TData extends Record<string, any> = Record<string,
334
334
  flexBasis: "auto" as const,
335
335
  };
336
336
  }
337
+ if (columnLayout === "equal" && !enableColumnResizing) return {};
337
338
  // Scroll layout needs one concrete width per column (from the column
338
339
  // def, TanStack default otherwise) so independent flex rows stay
339
340
  // aligned while the table grows past its container.
@@ -461,13 +462,13 @@ export function DataTableRoot<TData extends Record<string, any> = Record<string,
461
462
  // flexBasis auto, not the `flex: 0` shorthand: RN expands `flex: 0` to
462
463
  // basis 0, which overrides per-column widths and crushes every cell to
463
464
  // the minWidth floor (contents then overflow and get clipped).
464
- return { flexGrow: 0, flexShrink: 0, flexBasis: "auto" as const, minWidth: 80 };
465
+ return { flexGrow: 0, flexShrink: 0, flexBasis: "auto" as const, minWidth: 0 };
465
466
  }
466
467
  // Same flexBasis trap as the scroll branch above: `flex: 0` expands to
467
468
  // basis 0 and the per-column width from getColSizeProps loses, crushing
468
469
  // every resizable cell to the min-width floor (content then overlaps).
469
470
  if (enableColumnResizing) {
470
- return { flexGrow: 0, flexShrink: 0, flexBasis: "auto" as const };
471
+ return { flexGrow: 0, flexShrink: 0, flexBasis: "auto" as const, minWidth: 0 };
471
472
  }
472
473
  return { flex: 1, flexShrink: 1, flexGrow: 1, flexBasis: 0, minWidth: 0 };
473
474
  }, [isFlexLayout, columnLayout, enableColumnResizing]);
@@ -1124,6 +1125,12 @@ export function DataTableRoot<TData extends Record<string, any> = Record<string,
1124
1125
  [table, state, actions, computed, selectAllScope, filterUi],
1125
1126
  );
1126
1127
 
1128
+ const hasCardSort =
1129
+ showCards &&
1130
+ enableSorting &&
1131
+ table.getVisibleLeafColumns().some((column) => column.getCanSort());
1132
+ const defaultCardToolbar = showCards && !renderToolbar;
1133
+ const ToolbarComponent = defaultCardToolbar ? DataTableCardToolbar : DataTableToolbar;
1127
1134
  const renderToolbarComponent = () => {
1128
1135
  const activeFilterCount = table.getState().columnFilters?.length || 0;
1129
1136
 
@@ -1143,10 +1150,20 @@ export function DataTableRoot<TData extends Record<string, any> = Record<string,
1143
1150
  }
1144
1151
 
1145
1152
  return (
1146
- <DataTableToolbar
1153
+ <ToolbarComponent
1147
1154
  globalFilter={state.globalFilter || ""}
1148
1155
  onGlobalFilterChange={actions.setGlobalFilter}
1149
1156
  onClearFilters={actions.clearFilters}
1157
+ customActions={
1158
+ defaultCardToolbar && hasCardSort ? (
1159
+ <DataTableCardSortControl
1160
+ table={table}
1161
+ compact={isCompact}
1162
+ disabled={disabled}
1163
+ contained
1164
+ />
1165
+ ) : undefined
1166
+ }
1150
1167
  activeFilterCount={activeFilterCount}
1151
1168
  onSaveView={actions.saveView}
1152
1169
  onLoadView={actions.loadView}
@@ -1192,7 +1209,8 @@ export function DataTableRoot<TData extends Record<string, any> = Record<string,
1192
1209
  newValue: any,
1193
1210
  ) => Promise<void>,
1194
1211
  ) => {
1195
- const hasToolbar = enableGlobalSearch || enableFiltering || enableViews;
1212
+ const hasToolbar =
1213
+ enableGlobalSearch || enableFiltering || enableViews || (defaultCardToolbar && hasCardSort);
1196
1214
  const scrollContainerRef = useRef<HTMLDivElement>(null);
1197
1215
 
1198
1216
  // DF-12 / Axiom 6 HONEST STATE — a height-constrained body clips rows
@@ -1347,14 +1365,16 @@ export function DataTableRoot<TData extends Record<string, any> = Record<string,
1347
1365
  <>
1348
1366
  {/* Header affordances (sort / per-column filter) live in the
1349
1367
  grid headers; the card list restores them here. */}
1350
- <DataTableCardControls
1351
- table={table}
1352
- enableSorting={enableSorting}
1353
- enableFiltering={enableFiltering}
1354
- availableColumns={cardFilterColumns}
1355
- compact={isCompact}
1356
- disabled={disabled}
1357
- />
1368
+ {!defaultCardToolbar && (
1369
+ <DataTableCardControls
1370
+ table={table}
1371
+ enableSorting={enableSorting}
1372
+ enableFiltering={enableFiltering}
1373
+ availableColumns={cardFilterColumns}
1374
+ compact={isCompact}
1375
+ disabled={disabled}
1376
+ />
1377
+ )}
1358
1378
  <DataTableMobileCards
1359
1379
  tableRows={displayRows}
1360
1380
  enableRowSelection={enableRowSelection}
@@ -2085,7 +2105,31 @@ export function DataTableRoot<TData extends Record<string, any> = Record<string,
2085
2105
  <Table.Foot>
2086
2106
  {footerGroups.map((footerGroup: any) => (
2087
2107
  <Table.Row key={footerGroup.id}>
2108
+ {enableRowSelection && (
2109
+ <Table.Cell
2110
+ cellWidth={selectionColWidth}
2111
+ cellHeight={cellHeight as any}
2112
+ cellLocation="first"
2113
+ />
2114
+ )}
2115
+ {showRowNumbers && (
2116
+ <Table.Cell
2117
+ cellWidth={chrome.rowNumberCol}
2118
+ cellHeight={cellHeight as any}
2119
+ cellLocation={enableRowSelection ? "middle" : "first"}
2120
+ />
2121
+ )}
2122
+ {enableRowExpansion && (
2123
+ <Table.Cell
2124
+ cellWidth={chrome.expandCol}
2125
+ cellHeight={cellHeight as any}
2126
+ cellLocation={
2127
+ enableRowSelection || showRowNumbers ? "middle" : "first"
2128
+ }
2129
+ />
2130
+ )}
2088
2131
  {footerGroup.headers.map((header: any) => {
2132
+ const stickyStyle = getStickyStyle(header.column.id);
2089
2133
  const columnAlign = resolveColumnAlign({
2090
2134
  ...(header.column.columnDef as DataTableColumn),
2091
2135
  id: header.column.id,
@@ -2093,6 +2137,23 @@ export function DataTableRoot<TData extends Record<string, any> = Record<string,
2093
2137
  return (
2094
2138
  <Table.Cell
2095
2139
  key={header.id}
2140
+ {...cellFlexProps}
2141
+ {...getColSizeProps(header.column)}
2142
+ {...(isWeb && stickyStyle
2143
+ ? {
2144
+ style: {
2145
+ position: "sticky",
2146
+ ...("insetInlineStart" in stickyStyle && {
2147
+ insetInlineStart: stickyStyle.insetInlineStart,
2148
+ }),
2149
+ ...("insetInlineEnd" in stickyStyle && {
2150
+ insetInlineEnd: stickyStyle.insetInlineEnd,
2151
+ }),
2152
+ zIndex: 2,
2153
+ backgroundColor: "var(--color2)",
2154
+ } as React.CSSProperties,
2155
+ }
2156
+ : {})}
2096
2157
  justifyContent={alignToFlex(columnAlign)}
2097
2158
  data-align={columnAlign}
2098
2159
  >
@@ -2115,6 +2176,13 @@ export function DataTableRoot<TData extends Record<string, any> = Record<string,
2115
2176
  </Table.Cell>
2116
2177
  );
2117
2178
  })}
2179
+ {showDeleteRow && !readOnly && (
2180
+ <Table.Cell
2181
+ cellWidth={chrome.deleteCol}
2182
+ cellHeight={cellHeight as any}
2183
+ cellLocation="last"
2184
+ />
2185
+ )}
2118
2186
  </Table.Row>
2119
2187
  ))}
2120
2188
  </Table.Foot>
@@ -7,7 +7,7 @@
7
7
  * breakpoint switch are exercised without resizing a window.
8
8
  *
9
9
  * Covers: breakpoint/override switching, card anatomy (title slot +
10
- * label/value rows), column visibility, sorting order, global search,
10
+ * classified fields), column visibility, sorting order, global search,
11
11
  * column filters, the card sort/filter controls, the SimpleTable layout
12
12
  * override, and the Table primitives card layout.
13
13
  */
@@ -127,7 +127,7 @@ describe("DataTable card layout", () => {
127
127
  });
128
128
 
129
129
  describe("card anatomy", () => {
130
- it("renders the first visible column as the card title and the rest as label/value rows", () => {
130
+ it("renders the first visible column as the card title and classifies the rest", () => {
131
131
  const { container } = renderWithProviders(
132
132
  <DataTable data={sampleData} columns={columns} onStateChange={vi.fn()} />,
133
133
  );
@@ -140,15 +140,21 @@ describe("DataTable card layout", () => {
140
140
  expect(title!.textContent).toContain("John");
141
141
  expect(within(firstCard).queryByText("First Name")).toBeNull();
142
142
 
143
- // Remaining visible columns render as label/value rows.
144
- expect(within(firstCard).getByText("Last Name")).toBeDefined();
143
+ // A self-naming string goes bare. Its column name still reaches
144
+ // assistive tech, through a hidden node that costs no layout.
145
+ expect(within(firstCard).queryByText("Last Name")).toBeNull();
146
+ expect(within(firstCard).queryByText("Email")).toBeNull();
147
+ expect(firstCard.textContent).toContain("Last Name:");
148
+ expect(firstCard.textContent).toContain("Email:");
149
+
150
+ // A figure cannot be read without its name, so that one prints.
145
151
  expect(within(firstCard).getByText("Age")).toBeDefined();
146
- expect(within(firstCard).getByText("Email")).toBeDefined();
152
+
147
153
  expect(firstCard.textContent).toContain("Doe");
148
154
  expect(firstCard.textContent).toContain("28");
149
155
  });
150
156
 
151
- it("labels field rows from meta.label when the header is a render function", () => {
157
+ it("names a control from meta.label when the header is a render function", () => {
152
158
  const glyphColumns: DataTableColumn<Person>[] = [
153
159
  { id: "firstName", accessorKey: "firstName", header: "First Name" },
154
160
  {
@@ -162,7 +168,9 @@ describe("DataTable card layout", () => {
162
168
  <DataTable data={sampleData} columns={glyphColumns} onStateChange={vi.fn()} />,
163
169
  );
164
170
  const [firstCard] = getCards(container);
165
- expect(within(firstCard).getByText("Liked")).toBeDefined();
171
+ // A leading underscore marks a control: its own row, no printed label.
172
+ expect(within(firstCard).queryByText("Liked")).toBeNull();
173
+ expect(firstCard.textContent).toContain("Liked:");
166
174
  expect(firstCard.textContent).not.toContain("_liked_by");
167
175
  });
168
176
 
@@ -180,9 +188,11 @@ describe("DataTable card layout", () => {
180
188
  <DataTable data={sampleData} columns={mixedColumns} onStateChange={vi.fn()} />,
181
189
  );
182
190
  const [firstCard] = getCards(container);
191
+ // A figure prints its name, a bare string hides it. meta.label wins
192
+ // either way, and the id fallback stays honest.
183
193
  expect(within(firstCard).getByText("Age (years)")).toBeDefined();
184
194
  expect(within(firstCard).queryByText("♥")).toBeNull();
185
- expect(within(firstCard).getByText("email_addr")).toBeDefined();
195
+ expect(firstCard.textContent).toContain("email_addr:");
186
196
  });
187
197
 
188
198
  it("respects column visibility (hidden columns stay hidden in cards)", () => {
@@ -196,6 +206,7 @@ describe("DataTable card layout", () => {
196
206
  );
197
207
  const [firstCard] = getCards(container);
198
208
  expect(within(firstCard).queryByText("Email")).toBeNull();
209
+ expect(firstCard.textContent).not.toContain("Email:");
199
210
  expect(firstCard.textContent).not.toContain("john@example.com");
200
211
  });
201
212
  });
@@ -272,11 +283,12 @@ describe("DataTable card layout", () => {
272
283
  expect(titles).toEqual(["John"]);
273
284
  });
274
285
 
275
- it("renders the filter chrome (Add Filter) in card layout", () => {
276
- const { getByText } = renderWithProviders(
286
+ it("renders the canonical Filter in card layout", () => {
287
+ const { getByRole, queryByText } = renderWithProviders(
277
288
  <DataTable data={sampleData} columns={columns} onStateChange={vi.fn()} />,
278
289
  );
279
- expect(getByText("Add Filter")).toBeDefined();
290
+ expect(getByRole("button", { name: "Filter" })).toBeDefined();
291
+ expect(queryByText("Add Filter")).toBeNull();
280
292
  });
281
293
 
282
294
  it("hides sort/filter chrome when sorting and filtering are disabled", () => {