@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
@@ -0,0 +1,250 @@
1
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
2
+ import { Preset } from "@multiplatform.one/theme";
3
+ import { fireEvent, waitFor } from "@testing-library/react";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { DataTableColumn } from "../../types";
6
+ import { DataTable } from "./index";
7
+
8
+ // Happy DOM has no viewport measurements. Keep the actual virtualizer and row renderer.
9
+ vi.mock("@tanstack/react-virtual", async (importOriginal) => {
10
+ const actual = await importOriginal<typeof import("@tanstack/react-virtual")>();
11
+ return {
12
+ ...actual,
13
+ useVirtualizer: (options: Parameters<typeof actual.useVirtualizer>[0]) =>
14
+ actual.useVirtualizer({
15
+ ...options,
16
+ initialRect: { width: 800, height: 240 },
17
+ observeElementRect: (_instance, callback) => {
18
+ callback({ width: 800, height: 240 });
19
+ return () => {};
20
+ },
21
+ observeElementOffset: (_instance, callback) => {
22
+ callback(0, false);
23
+ return () => {};
24
+ },
25
+ scrollToFn: () => {},
26
+ }),
27
+ };
28
+ });
29
+
30
+ const data = [{ id: "1", account: "Account 1", balance: "10.00" }];
31
+ const columns: DataTableColumn<(typeof data)[number]>[] = [
32
+ { accessorKey: "account", header: "Account", size: 320, footer: "Totals" },
33
+ { accessorKey: "balance", header: "Balance", size: 80, footer: "10.00" },
34
+ ];
35
+ const allAuxiliaries = {
36
+ enableRowSelection: true,
37
+ showRowNumbers: true,
38
+ enableRowExpansion: true,
39
+ showDeleteRow: true,
40
+ };
41
+
42
+ function renderTable(props = {}, size: "small" | "medium" | "large" = "medium") {
43
+ return renderWithProviders(
44
+ <Preset overrides={{ size }}>
45
+ <DataTable
46
+ data={data}
47
+ columns={columns}
48
+ layout="table"
49
+ columnLayout="scroll"
50
+ enablePagination={false}
51
+ {...props}
52
+ />
53
+ </Preset>,
54
+ );
55
+ }
56
+
57
+ function cells(row: Element): HTMLElement[] {
58
+ return [...row.children].filter((element) =>
59
+ ["cell", "columnheader"].includes(element.getAttribute("role") ?? ""),
60
+ ) as HTMLElement[];
61
+ }
62
+
63
+ function width(cell: HTMLElement): number {
64
+ return Number.parseFloat(
65
+ cell.style.width || /(?:^| )_w-([\d.]+)px(?: |$)/.exec(cell.className)?.[1] || "NaN",
66
+ );
67
+ }
68
+
69
+ function rows(container: HTMLElement) {
70
+ const header = container.querySelector('thead [role="row"]')!;
71
+ const body = container.querySelector('[aria-label="Row 1"]')!;
72
+ const footer = container.querySelector('tfoot [role="row"]')!;
73
+ expect(header).not.toBeNull();
74
+ expect(body).not.toBeNull();
75
+ expect(footer).not.toBeNull();
76
+ return { header: cells(header), body: cells(body), footer: cells(footer) };
77
+ }
78
+
79
+ function expectPinned(cell: HTMLElement, side: "start" | "end", offset: number) {
80
+ expect(cell.style.position).toBe("sticky");
81
+ expect(cell.style[side === "start" ? "insetInlineStart" : "insetInlineEnd"]).toBe(`${offset}px`);
82
+ expect(cell.style.zIndex).toBe("2");
83
+ expect(cell.style.backgroundColor).toBe("var(--color2)");
84
+ }
85
+
86
+ let previousDirection: string | null;
87
+ beforeEach(() => {
88
+ previousDirection = document.documentElement.getAttribute("dir");
89
+ });
90
+ afterEach(() => {
91
+ if (previousDirection === null) document.documentElement.removeAttribute("dir");
92
+ else document.documentElement.setAttribute("dir", previousDirection);
93
+ });
94
+
95
+ describe.each(["ltr", "rtl"])("footer layout in %s", (direction) => {
96
+ beforeEach(() => document.documentElement.setAttribute("dir", direction));
97
+
98
+ it.each([
99
+ { name: "no auxiliaries", props: {}, leading: 0, trailing: 0 },
100
+ { name: "selection", props: { enableRowSelection: true }, leading: 1, trailing: 0 },
101
+ { name: "row numbers", props: { showRowNumbers: true }, leading: 1, trailing: 0 },
102
+ { name: "expansion", props: { enableRowExpansion: true }, leading: 1, trailing: 0 },
103
+ { name: "delete", props: { showDeleteRow: true }, leading: 0, trailing: 1 },
104
+ { name: "all auxiliaries", props: allAuxiliaries, leading: 3, trailing: 1 },
105
+ {
106
+ name: "read-only auxiliaries",
107
+ props: { ...allAuxiliaries, readOnly: true },
108
+ leading: 3,
109
+ trailing: 0,
110
+ },
111
+ {
112
+ name: "read-only delete suppressed",
113
+ props: { showDeleteRow: true, readOnly: true },
114
+ leading: 0,
115
+ trailing: 0,
116
+ },
117
+ {
118
+ name: "compact auxiliaries",
119
+ props: { ...allAuxiliaries, compact: true },
120
+ leading: 3,
121
+ trailing: 1,
122
+ },
123
+ ])("matches resolved header/body slots with $name", ({ props, leading, trailing }) => {
124
+ const { container } = renderTable(props);
125
+ const row = rows(container);
126
+ const headerWidths = row.header.map(width);
127
+ expect(row.header).toHaveLength(leading + 2 + trailing);
128
+ expect(headerWidths.every((value) => Number.isFinite(value) && value > 0)).toBe(true);
129
+ expect(headerWidths.slice(leading, leading + 2)).toEqual([320, 80]);
130
+ expect(row.body.map(width)).toEqual(headerWidths);
131
+ expect(row.footer.map(width)).toEqual(headerWidths);
132
+ expect(row.footer[leading]).toHaveTextContent("Totals");
133
+ expect(row.footer[leading + 1]).toHaveTextContent("10.00");
134
+ const auxiliaryCells = [...row.footer.slice(0, leading), ...row.footer.slice(leading + 2)];
135
+ for (const cell of auxiliaryCells) {
136
+ expect(cell.textContent).toBe("");
137
+ expect(cell.tabIndex).toBe(-1);
138
+ expect(
139
+ cell.querySelector('button, input, [role="button"], [role="checkbox"], [tabindex]'),
140
+ ).toBeNull();
141
+ }
142
+ });
143
+
144
+ it.each(["left", "right"] as const)(
145
+ "resolves pinned %s footer paint like header/body",
146
+ async (side) => {
147
+ const utils = renderTable({ enableColumnPinning: true });
148
+ fireEvent.click(utils.getByLabelText("Pin Account column left"));
149
+ if (side === "right") fireEvent.click(utils.getByLabelText("Pin Account column right"));
150
+ await waitFor(() =>
151
+ expect(
152
+ (utils.container.querySelector("#account-header") as HTMLElement).style.position,
153
+ ).toBe("sticky"),
154
+ );
155
+ const row = rows(utils.container);
156
+ const index = row.header.findIndex((cell) => cell.id === "account-header");
157
+ const logicalSide = side === "left" ? "start" : "end";
158
+ expectPinned(row.header[index], logicalSide, 0);
159
+ expectPinned(row.body[index], logicalSide, 0);
160
+ expectPinned(row.footer[index], logicalSide, 0);
161
+ expect(row.footer[1 - index].style.position).not.toBe("sticky");
162
+ },
163
+ );
164
+
165
+ it("follows resolved auxiliary widths at small and large preset sizes", () => {
166
+ const widthsBySize = ["small", "large"].map((size) => {
167
+ const utils = renderTable(allAuxiliaries, size as "small" | "large");
168
+ const row = rows(utils.container);
169
+ const headerWidths = row.header.map(width);
170
+ expect(headerWidths).toHaveLength(6);
171
+ expect(headerWidths.slice(3, 5)).toEqual([320, 80]);
172
+ expect(row.body.map(width)).toEqual(headerWidths);
173
+ expect(row.footer.map(width)).toEqual(headerWidths);
174
+ utils.unmount();
175
+ return headerWidths;
176
+ });
177
+ for (const index of [0, 1, 2, 5]) {
178
+ expect(widthsBySize[0][index]).toBeLessThan(widthsBySize[1][index]);
179
+ }
180
+ });
181
+
182
+ it.each([false, true])(
183
+ "reserves auxiliary flow around both pin sides, readOnly=%s",
184
+ async (readOnly) => {
185
+ const utils = renderTable({ ...allAuxiliaries, readOnly, enableColumnPinning: true });
186
+ fireEvent.click(utils.getByLabelText("Pin Account column left"));
187
+ fireEvent.click(utils.getByLabelText("Pin Balance column left"));
188
+ fireEvent.click(utils.getByLabelText("Pin Balance column right"));
189
+ await waitFor(() => expect(rows(utils.container).header[3].style.position).toBe("sticky"));
190
+ const row = rows(utils.container);
191
+ const headerWidths = row.header.map(width);
192
+ const leadingWidth = headerWidths.slice(0, 3).reduce((sum, value) => sum + value, 0);
193
+ const trailingWidth = readOnly ? 0 : headerWidths[5];
194
+ expect(leadingWidth).toBeGreaterThan(0);
195
+ expect(row.body.map(width)).toEqual(headerWidths);
196
+ expect(row.footer.map(width)).toEqual(headerWidths);
197
+ for (const section of [row.header, row.body, row.footer]) {
198
+ expectPinned(section[3], "start", leadingWidth);
199
+ expectPinned(section[4], "end", trailingWidth);
200
+ }
201
+ },
202
+ );
203
+
204
+ it("keeps actual virtual rows and auxiliary footer slots aligned after pinned resize", async () => {
205
+ const utils = renderWithProviders(
206
+ <DataTable
207
+ data={[
208
+ { id: "1", rank: 1, label: "Record 1" },
209
+ { id: "2", rank: 2, label: "Record 2" },
210
+ ]}
211
+ columns={[
212
+ { accessorKey: "rank", header: "Rank", size: 48, minSize: 32, maxSize: 64, footer: "2" },
213
+ { accessorKey: "label", header: "Label", size: 320, footer: "Total" },
214
+ ]}
215
+ layout="table"
216
+ columnLayout="scroll"
217
+ enableColumnResizing
218
+ enableColumnPinning
219
+ enableVirtualization
220
+ virtualizationHeight={240}
221
+ enablePagination={false}
222
+ {...allAuxiliaries}
223
+ />,
224
+ );
225
+ const virtualRows = () =>
226
+ utils.container.querySelectorAll('tbody[role="presentation"] [role="rowgroup"] [role="row"]');
227
+ await waitFor(() => expect(virtualRows()).toHaveLength(2));
228
+ fireEvent.click(utils.getByLabelText("Pin Rank column left"));
229
+ fireEvent.click(utils.getByLabelText("Pin Label column left"));
230
+ const header = () => cells(utils.container.querySelector('thead [role="row"]')!);
231
+ const leadingWidth = header()
232
+ .slice(0, 3)
233
+ .reduce((sum, cell) => sum + width(cell), 0);
234
+ expect(width(header()[3])).toBe(48);
235
+ expectPinned(header()[4], "start", leadingWidth + 48);
236
+ const handle = utils.getByRole("separator", { name: "Resize Rank column" });
237
+ fireEvent.mouseDown(handle, { clientX: 100 });
238
+ fireEvent.mouseMove(document, { clientX: 130 });
239
+ fireEvent.mouseUp(document, { clientX: 130 });
240
+ expect(width(header()[3])).toBe(64);
241
+ expectPinned(header()[4], "start", leadingWidth + 64);
242
+ const footer = cells(utils.container.querySelector('tfoot [role="row"]')!);
243
+ expect(virtualRows()).toHaveLength(2);
244
+ for (const section of [...virtualRows()].map(cells).concat([footer])) {
245
+ expect(section.map(width)).toEqual(header().map(width));
246
+ expectPinned(section[3], "start", leadingWidth);
247
+ expectPinned(section[4], "start", leadingWidth + 64);
248
+ }
249
+ });
250
+ });
@@ -17,12 +17,12 @@ import { FlashList, type FlashListRef } from "@shopify/flash-list";
17
17
  import { AsyncBoundary, EmptyState, useDirection } from "@multiplatform.one/components";
18
18
  import { Button, Field, useFormField } from "@multiplatform.one/forms";
19
19
  import { flexRender } from "@tanstack/react-table";
20
- import { hairline, useResolvedKnobs } from "@multiplatform.one/theme";
20
+ import { useResolvedKnobs } from "@multiplatform.one/theme";
21
21
  import React, { useRef, useMemo, useCallback } from "react";
22
22
  import { ScrollView, Text, View, useMedia, withStaticProperties } from "tamagui";
23
23
  import { useTranslation } from "react-i18next";
24
24
  import { useDataTable } from "../../hooks/useDataTable";
25
- import type { DataTableColumn, DataTableField } from "../../types";
25
+ import type { DataTableColumn, DataTableField, SelectAllScope } from "../../types";
26
26
  import { DEFAULT_PAGE_SIZE_OPTIONS } from "../../utils/paginationDefaults";
27
27
  import { withInterp } from "../../utils/withInterp";
28
28
  import { DataTableColumnPinning } from "../DataTableColumnPinning";
@@ -38,6 +38,7 @@ import { DataTableCell } from "../DataTableCell";
38
38
  import { Table } from "@multiplatform.one/table-primitives";
39
39
  import { DataTableContext } from "./DataTableContext";
40
40
  import { DataTableDocumentList } from "./DataTableDocumentList";
41
+ import { DataTableMobileCards } from "./DataTableMobileCards";
41
42
  import { dataTableChrome, resolveDataTableIntent, rowIntentProps } from "./dataTableChrome";
42
43
  import { tableRowStateProps } from "./rowStateChrome";
43
44
  import { DataTableStateContext } from "./DataTableStateContext";
@@ -134,6 +135,8 @@ function DataTableRoot<TData extends Record<string, any> = Record<string, any>>(
134
135
  virtualizationOverscan = 5,
135
136
  enableRowExpansion = false,
136
137
  renderSubComponent,
138
+ enableRowSelection = false,
139
+ selectAllScope: selectAllScopeProp,
137
140
  enableColumnPinning = false,
138
141
  maxPinnedColumns = 3,
139
142
  enableKeyboardNavigation: _enableKeyboardNavigation = false,
@@ -181,6 +184,9 @@ function DataTableRoot<TData extends Record<string, any> = Record<string, any>>(
181
184
  compact,
182
185
  });
183
186
  const chrome = dataTableChrome(String(knobProps.sizeToken));
187
+ // House selectAllScope knob (DG-TBL-03): prop overrides knob; default page.
188
+ const selectAllScope: SelectAllScope =
189
+ selectAllScopeProp ?? (knobProps.selectAllScope === "filtered" ? "filtered" : "page");
184
190
  const rowHeightPx = chrome.rowPx;
185
191
  // Collapsed expand-caret points toward inline-end, so it mirrors in RTL via
186
192
  // glyph swap (useDirection, Axiom 15); rotation sign flips so expanded
@@ -216,6 +222,7 @@ function DataTableRoot<TData extends Record<string, any> = Record<string, any>>(
216
222
  virtualizationOverscan,
217
223
  enableRowExpansion,
218
224
  renderSubComponent,
225
+ enableRowSelection,
219
226
  enableColumnPinning,
220
227
  maxPinnedColumns,
221
228
  enableKeyboardNavigation: false,
@@ -225,6 +232,25 @@ function DataTableRoot<TData extends Record<string, any> = Record<string, any>>(
225
232
  isFetching: isFetchingProp,
226
233
  });
227
234
 
235
+ const selectAllChecked =
236
+ enableRowSelection &&
237
+ (selectAllScope === "filtered"
238
+ ? table.getIsAllRowsSelected()
239
+ : table.getIsAllPageRowsSelected());
240
+ const selectAllIndeterminate =
241
+ enableRowSelection &&
242
+ (selectAllScope === "filtered"
243
+ ? table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()
244
+ : table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected());
245
+
246
+ const toggleSelectAll = useCallback(() => {
247
+ if (selectAllScope === "filtered") {
248
+ table.toggleAllRowsSelected();
249
+ } else {
250
+ table.toggleAllPageRowsSelected();
251
+ }
252
+ }, [selectAllScope, table]);
253
+
228
254
  const resolvedSize: "sm" | "md" | "lg" =
229
255
  size ?? (knobProps.sizeToken === "$3" ? "sm" : knobProps.sizeToken === "$5" ? "lg" : "md");
230
256
 
@@ -493,8 +519,8 @@ function DataTableRoot<TData extends Record<string, any> = Record<string, any>>(
493
519
  // (see utils/rowPress.ts). Roving tabindex is a web-only concern.
494
520
  const rowPressActive = Boolean(onRowPress) && !disabled;
495
521
 
496
- const deriveRowLabel = useCallback(
497
- (row: (typeof tableRows)[0], index: number): string => {
522
+ const deriveRowIdentity = useCallback(
523
+ (row: (typeof tableRows)[0]): string | undefined => {
498
524
  const fromAccessor = rowLabel?.(row.original as TData);
499
525
  if (fromAccessor) return fromAccessor;
500
526
  let firstCellValue: unknown;
@@ -509,13 +535,33 @@ function DataTableRoot<TData extends Record<string, any> = Record<string, any>>(
509
535
  ) {
510
536
  return String(firstCellValue);
511
537
  }
512
- return withInterp(t("Row {{row}}", { row: index + 1 }), { row: index + 1 });
538
+ return undefined;
513
539
  },
514
- [rowLabel, t],
540
+ [rowLabel],
541
+ );
542
+
543
+ const deriveRowLabel = useCallback(
544
+ (row: (typeof tableRows)[0], index: number): string =>
545
+ deriveRowIdentity(row) ??
546
+ withInterp(t("Row {{row}}", { row: index + 1 }), { row: index + 1 }),
547
+ [deriveRowIdentity, t],
548
+ );
549
+
550
+ // Selection-toggle name: row identity beats a bare index ("Select row
551
+ // Bulbasaur", falling back to "Select row 3"), the same source the grid
552
+ // and the card list both read.
553
+ const deriveSelectRowLabel = useCallback(
554
+ (row: (typeof tableRows)[0], index: number): string => {
555
+ const identity = deriveRowIdentity(row) ?? String(index + 1);
556
+ return withInterp(t("Select row {{row}}", { row: identity }), { row: identity });
557
+ },
558
+ [deriveRowIdentity, t],
515
559
  );
516
560
 
517
561
  const getRowPressProps = useCallback(
518
- (row: (typeof tableRows)[0], index: number) => {
562
+ // The card list asks for the "card" variant. Native carries no DOM row
563
+ // role for it to override, so both variants land on role="button".
564
+ (row: (typeof tableRows)[0], index: number, _variant: "row" | "card" = "row") => {
519
565
  if (!rowPressActive) return {};
520
566
  return {
521
567
  onPress: () => onRowPress?.(row.original as TData, index),
@@ -551,101 +597,6 @@ function DataTableRoot<TData extends Record<string, any> = Record<string, any>>(
551
597
  [renderRowContent, tableZebraOn, t, getRowPressProps],
552
598
  );
553
599
 
554
- const renderMobileCard = useCallback(
555
- (row: (typeof tableRows)[0], index: number, onDelete?: (idx: number) => void) => {
556
- const rowData = row.original as TData;
557
- const visibleCells = row.getVisibleCells();
558
- return (
559
- <View
560
- key={`${row.id}-${index}`}
561
- backgroundColor="$color1"
562
- borderRadius="$4"
563
- borderWidth={1}
564
- borderColor="$borderColor"
565
- padding="$3"
566
- marginBottom="$2"
567
- {...(getRowPressProps(row, index) as any)}
568
- {...(rowIntentProps(rowData) as any)}
569
- >
570
- {showRowNumbers && (
571
- <View
572
- flexDirection="row"
573
- justifyContent="space-between"
574
- paddingBottom="$2"
575
- {...hairline.bottom}
576
- borderBottomColor="$borderColor"
577
- marginBottom="$2"
578
- >
579
- <Text fontSize="$3" color="$color10" fontWeight="600">
580
- #{index + 1}
581
- </Text>
582
- {showDeleteRow && !readOnly && (
583
- <Button
584
- chromeless
585
- circular
586
- size="$2"
587
- icon={TrashIcon}
588
- disabled={disabled || tableRows.length <= minRows}
589
- onPress={() => onDelete?.(index)}
590
- aria-label={withInterp(t("Delete row {{row}}", { row: index + 1 }), {
591
- row: index + 1,
592
- })}
593
- />
594
- )}
595
- </View>
596
- )}
597
- {visibleCells.map((cell: any) => {
598
- const columnDef = cell.column.columnDef as DataTableColumn;
599
- const headerLabel =
600
- typeof columnDef.header === "string"
601
- ? columnDef.header
602
- : columnDef.id || cell.column.id;
603
- return (
604
- <View
605
- key={cell.id}
606
- flexDirection="row"
607
- alignItems="center"
608
- paddingVertical="$1.5"
609
- gap="$2"
610
- >
611
- <Text fontSize="$3" color="$color10" fontWeight="500" width="40%" flexShrink={0}>
612
- {headerLabel}
613
- </Text>
614
- <View flex={1}>{renderCellContent(cell, index, rowData)}</View>
615
- </View>
616
- );
617
- })}
618
- {!showRowNumbers && showDeleteRow && !readOnly && (
619
- <View paddingTop="$2" alignItems="flex-end">
620
- <Button
621
- chromeless
622
- circular
623
- size="$2"
624
- icon={TrashIcon}
625
- disabled={disabled || tableRows.length <= minRows}
626
- onPress={() => onDelete?.(index)}
627
- aria-label={withInterp(t("Delete row {{row}}", { row: index + 1 }), {
628
- row: index + 1,
629
- })}
630
- />
631
- </View>
632
- )}
633
- </View>
634
- );
635
- },
636
- [
637
- showRowNumbers,
638
- showDeleteRow,
639
- readOnly,
640
- disabled,
641
- tableRows.length,
642
- minRows,
643
- renderCellContent,
644
- t,
645
- getRowPressProps,
646
- ],
647
- );
648
-
649
600
  const renderToolbarComponent = () => {
650
601
  if (renderToolbar) {
651
602
  return renderToolbar({
@@ -777,36 +728,37 @@ function DataTableRoot<TData extends Record<string, any> = Record<string, any>>(
777
728
  {showList ? (
778
729
  <DataTableDocumentList
779
730
  tableRows={displayRows}
731
+ enableRowSelection={enableRowSelection}
780
732
  disabled={disabled}
781
733
  hasActiveFilters={computed.hasActiveFilters}
782
734
  renderCellContent={renderCellContent}
783
735
  compact={isCompact}
736
+ selectRowLabel={deriveSelectRowLabel}
784
737
  queryInFlight={queryInFlight}
785
738
  {...(rowPressActive ? { getRowPressProps } : {})}
786
739
  />
787
740
  ) : showCards ? (
788
741
  <View flexDirection="column" gap="$2">
789
- {displayRows.length === 0 ? (
790
- queryInFlight ? null : (
791
- <EmptyState
792
- compact
793
- backgroundColor="$color1"
794
- borderRadius="$4"
795
- aria-live="polite"
796
- title={
797
- computed.hasActiveFilters ? t("No results found") : t("No data available")
798
- }
799
- />
800
- )
801
- ) : (
802
- displayRows.map((row, index) =>
803
- renderMobileCard(
804
- row,
805
- index,
806
- isFormMode ? effectiveHandleDeleteRow : handleDeleteRow,
807
- ),
808
- )
809
- )}
742
+ <DataTableMobileCards
743
+ tableRows={displayRows}
744
+ enableRowSelection={enableRowSelection}
745
+ showRowNumbers={showRowNumbers}
746
+ showDeleteRow={showDeleteRow}
747
+ readOnly={readOnly}
748
+ disabled={disabled}
749
+ dataLength={tableData.length}
750
+ minRows={minRows}
751
+ hasActiveFilters={computed.hasActiveFilters}
752
+ renderCellContent={renderCellContent}
753
+ onDeleteRow={isFormMode ? effectiveHandleDeleteRow : handleDeleteRow}
754
+ selectAllChecked={selectAllChecked}
755
+ selectAllIndeterminate={selectAllIndeterminate}
756
+ onToggleSelectAll={toggleSelectAll}
757
+ compact={isCompact}
758
+ selectRowLabel={deriveSelectRowLabel}
759
+ queryInFlight={queryInFlight}
760
+ {...(rowPressActive ? { getRowPressProps } : {})}
761
+ />
810
762
  {enablePagination && (
811
763
  <View width="100%" {...(disabled ? disabledState.chromeKnobProps : {})}>
812
764
  {renderPaginationComponent()}
@@ -1099,7 +1051,6 @@ function DataTableRoot<TData extends Record<string, any> = Record<string, any>>(
1099
1051
  virtualizationHeight,
1100
1052
  virtualizationOverscan,
1101
1053
  renderRow,
1102
- renderMobileCard,
1103
1054
  renderSubComponent,
1104
1055
  footerGroups,
1105
1056
  sm,
@@ -1118,6 +1069,11 @@ function DataTableRoot<TData extends Record<string, any> = Record<string, any>>(
1118
1069
  renderCellContent,
1119
1070
  rowPressActive,
1120
1071
  getRowPressProps,
1072
+ enableRowSelection,
1073
+ selectAllChecked,
1074
+ selectAllIndeterminate,
1075
+ toggleSelectAll,
1076
+ deriveSelectRowLabel,
1121
1077
  displayRows,
1122
1078
  queryInFlight,
1123
1079
  t,
@@ -0,0 +1,95 @@
1
+ /**
2
+ * One card renderer, two platforms.
3
+ *
4
+ * DataTableRoot.tsx (web) and index.native.tsx (native) are separate roots
5
+ * because native virtualizes through FlashList. The CARD LIST is not part
6
+ * of that split — a card has no platform-specific anatomy — so both roots
7
+ * render the same DataTableMobileCards rather than each drawing a card.
8
+ *
9
+ * MPO-285 shared the field RULE (classifyCardField, getColumnLabel) while
10
+ * leaving two copies of the JSX, and the two had already drifted: the
11
+ * native copy hardcoded its surface, and shipped without zebra striping,
12
+ * the selection checkbox, the empty states, and the TableCellContext that
13
+ * makes a field render chromeless. Sharing the rule alone does not stop
14
+ * that, because a new slot still has to be added twice.
15
+ *
16
+ * These assertions read source rather than render: the native root pulls in
17
+ * FlashList and the suite runs under happy-dom, so index.native.tsx is
18
+ * never the module a spec resolves. What the card DOES on native is covered
19
+ * by dataTableMobileCards.spec.tsx, which now describes both platforms.
20
+ */
21
+
22
+ import { readdirSync, readFileSync } from "node:fs";
23
+ import { resolve } from "node:path";
24
+ import { describe, expect, it } from "vitest";
25
+
26
+ const DIR = resolve(__dirname);
27
+
28
+ function read(file: string): string {
29
+ return readFileSync(resolve(DIR, file), "utf-8");
30
+ }
31
+
32
+ /** A `<Component ... />` element at a call site, as text. */
33
+ function callSite(source: string, component: string): string {
34
+ const start = source.indexOf(`<${component}`);
35
+ if (start === -1) return "";
36
+ const end = source.indexOf("/>", start);
37
+ return end === -1 ? "" : source.slice(start, end);
38
+ }
39
+
40
+ /** Prop names that call site passes. */
41
+ function propNames(source: string, component: string): Set<string> {
42
+ const names = new Set<string>();
43
+ for (const match of callSite(source, component).matchAll(/(?:^|\s)([A-Za-z][A-Za-z0-9]*)=\{/g)) {
44
+ names.add(match[1]!);
45
+ }
46
+ return names;
47
+ }
48
+
49
+ const cardCallSite = (source: string) => callSite(source, "DataTableMobileCards");
50
+
51
+ describe("DataTable card renderer parity", () => {
52
+ it("the native root renders DataTableMobileCards and carries no card of its own", () => {
53
+ const native = read("index.native.tsx");
54
+ expect(native).toMatch(
55
+ /import \{[^}]*DataTableMobileCards[^}]*\} from "\.\/DataTableMobileCards"/,
56
+ );
57
+ expect(cardCallSite(native)).not.toBe("");
58
+ expect(native).not.toMatch(/renderMobileCard/);
59
+ });
60
+
61
+ it("the native root reaches the card field rule through the component, not directly", () => {
62
+ // MPO-285 left index.native.tsx importing classifyCardField/getColumnLabel
63
+ // to feed its own JSX. With the JSX shared there is nothing left to feed:
64
+ // an import here again would mean a second card had grown back.
65
+ const native = read("index.native.tsx");
66
+ expect(native).not.toMatch(/\bclassifyCardField\b/);
67
+ expect(native).not.toMatch(/\bgetColumnLabel\b/);
68
+ });
69
+
70
+ it("exactly one file in the component draws a card", () => {
71
+ const drawers = readdirSync(DIR)
72
+ .filter(
73
+ (file) => file.endsWith(".tsx") && !file.includes(".spec.") && !file.includes(".stories."),
74
+ )
75
+ .filter((file) => read(file).includes('testID="data-table-card"'));
76
+ expect(drawers).toEqual(["DataTableMobileCards.tsx"]);
77
+ });
78
+
79
+ // Both roots hand their row views the same contract. A prop web passes and
80
+ // native does not is how the card lost selection and the empty states in
81
+ // the first place, so it is the drift worth failing a build over.
82
+ it.each(["DataTableMobileCards", "DataTableDocumentList"])(
83
+ "native passes every %s prop web passes",
84
+ (component) => {
85
+ const web = propNames(read("DataTableRoot.tsx"), component);
86
+ const native = propNames(read("index.native.tsx"), component);
87
+ expect(web.size).toBeGreaterThan(0);
88
+ expect([...web].filter((prop) => !native.has(prop)).sort()).toEqual([]);
89
+ },
90
+ );
91
+
92
+ it("native hands the card list its press props, so a card is pressable there too", () => {
93
+ expect(cardCallSite(read("index.native.tsx"))).toMatch(/getRowPressProps/);
94
+ });
95
+ });