@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.
- package/README.md +60 -0
- package/dist/cjs/components/DataTable/DataTableCardControls.cjs +90 -54
- package/dist/cjs/components/DataTable/DataTableCardControls.native.js +93 -56
- package/dist/cjs/components/DataTable/DataTableCardControls.native.js.map +1 -1
- package/dist/cjs/components/DataTable/DataTableMobileCards.cjs +100 -33
- package/dist/cjs/components/DataTable/DataTableMobileCards.native.js +139 -38
- package/dist/cjs/components/DataTable/DataTableMobileCards.native.js.map +1 -1
- package/dist/cjs/components/DataTable/DataTableRoot.cjs +51 -8
- package/dist/cjs/components/DataTable/DataTableRoot.native.js +53 -9
- package/dist/cjs/components/DataTable/DataTableRoot.native.js.map +1 -1
- package/dist/cjs/components/DataTable/index.native.js +59 -93
- package/dist/cjs/components/DataTable/index.native.js.map +1 -1
- package/dist/cjs/components/DataTableFilterButton/index.cjs +14 -0
- package/dist/cjs/components/DataTableFilterButton/index.native.js +15 -0
- package/dist/cjs/components/DataTableFilterButton/index.native.js.map +1 -1
- package/dist/cjs/components/DataTableToolbar/index.cjs +178 -101
- package/dist/cjs/components/DataTableToolbar/index.native.js +187 -109
- package/dist/cjs/components/DataTableToolbar/index.native.js.map +1 -1
- package/dist/cjs/hooks/useDataTable.cjs +32 -1
- package/dist/cjs/hooks/useDataTable.native.js +37 -1
- package/dist/cjs/hooks/useDataTable.native.js.map +1 -1
- package/dist/esm/components/DataTable/DataTableCardControls.mjs +89 -54
- package/dist/esm/components/DataTable/DataTableCardControls.mjs.map +1 -1
- package/dist/esm/components/DataTable/DataTableCardControls.native.js +92 -56
- package/dist/esm/components/DataTable/DataTableCardControls.native.js.map +1 -1
- package/dist/esm/components/DataTable/DataTableMobileCards.mjs +99 -34
- package/dist/esm/components/DataTable/DataTableMobileCards.mjs.map +1 -1
- package/dist/esm/components/DataTable/DataTableMobileCards.native.js +138 -39
- package/dist/esm/components/DataTable/DataTableMobileCards.native.js.map +1 -1
- package/dist/esm/components/DataTable/DataTableRoot.mjs +53 -10
- package/dist/esm/components/DataTable/DataTableRoot.mjs.map +1 -1
- package/dist/esm/components/DataTable/DataTableRoot.native.js +55 -11
- package/dist/esm/components/DataTable/DataTableRoot.native.js.map +1 -1
- package/dist/esm/components/DataTable/index.native.js +60 -94
- package/dist/esm/components/DataTable/index.native.js.map +1 -1
- package/dist/esm/components/DataTableFilterButton/index.mjs +14 -1
- package/dist/esm/components/DataTableFilterButton/index.mjs.map +1 -1
- package/dist/esm/components/DataTableFilterButton/index.native.js +15 -1
- package/dist/esm/components/DataTableFilterButton/index.native.js.map +1 -1
- package/dist/esm/components/DataTableToolbar/index.mjs +180 -104
- package/dist/esm/components/DataTableToolbar/index.mjs.map +1 -1
- package/dist/esm/components/DataTableToolbar/index.native.js +189 -112
- package/dist/esm/components/DataTableToolbar/index.native.js.map +1 -1
- package/dist/esm/hooks/useDataTable.mjs +32 -1
- package/dist/esm/hooks/useDataTable.mjs.map +1 -1
- package/dist/esm/hooks/useDataTable.native.js +37 -1
- package/dist/esm/hooks/useDataTable.native.js.map +1 -1
- package/dist/jsx/components/DataTable/DataTableCardControls.mjs +89 -54
- package/dist/jsx/components/DataTable/DataTableCardControls.mjs.map +1 -1
- package/dist/jsx/components/DataTable/DataTableCardControls.native.js +93 -56
- package/dist/jsx/components/DataTable/DataTableCardControls.native.js.map +1 -1
- package/dist/jsx/components/DataTable/DataTableMobileCards.mjs +99 -34
- package/dist/jsx/components/DataTable/DataTableMobileCards.mjs.map +1 -1
- package/dist/jsx/components/DataTable/DataTableMobileCards.native.js +139 -38
- package/dist/jsx/components/DataTable/DataTableMobileCards.native.js.map +1 -1
- package/dist/jsx/components/DataTable/DataTableRoot.mjs +53 -10
- package/dist/jsx/components/DataTable/DataTableRoot.mjs.map +1 -1
- package/dist/jsx/components/DataTable/DataTableRoot.native.js +53 -9
- package/dist/jsx/components/DataTable/DataTableRoot.native.js.map +1 -1
- package/dist/jsx/components/DataTable/index.native.js +59 -93
- package/dist/jsx/components/DataTable/index.native.js.map +1 -1
- package/dist/jsx/components/DataTableFilterButton/index.mjs +14 -1
- package/dist/jsx/components/DataTableFilterButton/index.mjs.map +1 -1
- package/dist/jsx/components/DataTableFilterButton/index.native.js +15 -0
- package/dist/jsx/components/DataTableFilterButton/index.native.js.map +1 -1
- package/dist/jsx/components/DataTableToolbar/index.mjs +180 -104
- package/dist/jsx/components/DataTableToolbar/index.mjs.map +1 -1
- package/dist/jsx/components/DataTableToolbar/index.native.js +187 -109
- package/dist/jsx/components/DataTableToolbar/index.native.js.map +1 -1
- package/dist/jsx/hooks/useDataTable.mjs +32 -1
- package/dist/jsx/hooks/useDataTable.mjs.map +1 -1
- package/dist/jsx/hooks/useDataTable.native.js +37 -1
- package/dist/jsx/hooks/useDataTable.native.js.map +1 -1
- package/package.json +14 -14
- package/src/components/DataTable/DataTable.spec.tsx +5 -3
- package/src/components/DataTable/DataTable.stories.tsx +59 -0
- package/src/components/DataTable/DataTableCardControls.stories.tsx +61 -2
- package/src/components/DataTable/DataTableCardControls.tsx +92 -66
- package/src/components/DataTable/DataTableMobileCards.tsx +162 -39
- package/src/components/DataTable/DataTableRoot.tsx +82 -14
- package/src/components/DataTable/cardLayout.spec.tsx +23 -11
- package/src/components/DataTable/cardToolbar.spec.tsx +238 -0
- package/src/components/DataTable/chromeInterplay.spec.tsx +7 -5
- package/src/components/DataTable/columnWidthLayout.spec.tsx +189 -0
- package/src/components/DataTable/dataTableCardControls.spec.tsx +3 -2
- package/src/components/DataTable/dataTableMobileCards.spec.tsx +50 -2
- package/src/components/DataTable/footerLayout.spec.tsx +250 -0
- package/src/components/DataTable/index.native.tsx +80 -124
- package/src/components/DataTable/nativeCardParity.spec.tsx +95 -0
- package/src/components/DataTable/pinningGeometry.spec.tsx +66 -1
- package/src/components/DataTableExport/DataTableExport.spec.tsx +42 -18
- package/src/components/DataTableFilterButton/index.tsx +9 -0
- package/src/components/DataTableToolbar/index.tsx +184 -105
- package/src/hooks/useDataTable.spec.ts +114 -0
- package/src/hooks/useDataTable.ts +23 -1
- package/src/types.ts +14 -12
- package/types/components/DataTable/DataTableCardControls.d.ts +3 -0
- package/types/components/DataTable/DataTableCardControls.d.ts.map +1 -1
- package/types/components/DataTable/DataTableMobileCards.d.ts +42 -8
- package/types/components/DataTable/DataTableMobileCards.d.ts.map +1 -1
- package/types/components/DataTable/DataTableRoot.d.ts.map +1 -1
- package/types/components/DataTable/index.native.d.ts +1 -1
- package/types/components/DataTable/index.native.d.ts.map +1 -1
- package/types/components/DataTableFilterButton/index.d.ts +2 -1
- package/types/components/DataTableFilterButton/index.d.ts.map +1 -1
- package/types/components/DataTableToolbar/index.d.ts +1 -0
- package/types/components/DataTableToolbar/index.d.ts.map +1 -1
- package/types/hooks/useDataTable.d.ts.map +1 -1
- package/types/types.d.ts +14 -12
- 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 {
|
|
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
|
|
497
|
-
(row: (typeof tableRows)[0]
|
|
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
|
|
538
|
+
return undefined;
|
|
513
539
|
},
|
|
514
|
-
[rowLabel
|
|
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
|
-
|
|
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
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
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
|
+
});
|