@multiplatform.one/table 7.10.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 (84) 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/DataTableRoot.cjs +51 -8
  6. package/dist/cjs/components/DataTable/DataTableRoot.native.js +53 -9
  7. package/dist/cjs/components/DataTable/DataTableRoot.native.js.map +1 -1
  8. package/dist/cjs/components/DataTableFilterButton/index.cjs +14 -0
  9. package/dist/cjs/components/DataTableFilterButton/index.native.js +15 -0
  10. package/dist/cjs/components/DataTableFilterButton/index.native.js.map +1 -1
  11. package/dist/cjs/components/DataTableToolbar/index.cjs +178 -101
  12. package/dist/cjs/components/DataTableToolbar/index.native.js +187 -109
  13. package/dist/cjs/components/DataTableToolbar/index.native.js.map +1 -1
  14. package/dist/cjs/hooks/useDataTable.cjs +32 -1
  15. package/dist/cjs/hooks/useDataTable.native.js +37 -1
  16. package/dist/cjs/hooks/useDataTable.native.js.map +1 -1
  17. package/dist/esm/components/DataTable/DataTableCardControls.mjs +89 -54
  18. package/dist/esm/components/DataTable/DataTableCardControls.mjs.map +1 -1
  19. package/dist/esm/components/DataTable/DataTableCardControls.native.js +92 -56
  20. package/dist/esm/components/DataTable/DataTableCardControls.native.js.map +1 -1
  21. package/dist/esm/components/DataTable/DataTableRoot.mjs +53 -10
  22. package/dist/esm/components/DataTable/DataTableRoot.mjs.map +1 -1
  23. package/dist/esm/components/DataTable/DataTableRoot.native.js +55 -11
  24. package/dist/esm/components/DataTable/DataTableRoot.native.js.map +1 -1
  25. package/dist/esm/components/DataTableFilterButton/index.mjs +14 -1
  26. package/dist/esm/components/DataTableFilterButton/index.mjs.map +1 -1
  27. package/dist/esm/components/DataTableFilterButton/index.native.js +15 -1
  28. package/dist/esm/components/DataTableFilterButton/index.native.js.map +1 -1
  29. package/dist/esm/components/DataTableToolbar/index.mjs +180 -104
  30. package/dist/esm/components/DataTableToolbar/index.mjs.map +1 -1
  31. package/dist/esm/components/DataTableToolbar/index.native.js +189 -112
  32. package/dist/esm/components/DataTableToolbar/index.native.js.map +1 -1
  33. package/dist/esm/hooks/useDataTable.mjs +32 -1
  34. package/dist/esm/hooks/useDataTable.mjs.map +1 -1
  35. package/dist/esm/hooks/useDataTable.native.js +37 -1
  36. package/dist/esm/hooks/useDataTable.native.js.map +1 -1
  37. package/dist/jsx/components/DataTable/DataTableCardControls.mjs +89 -54
  38. package/dist/jsx/components/DataTable/DataTableCardControls.mjs.map +1 -1
  39. package/dist/jsx/components/DataTable/DataTableCardControls.native.js +93 -56
  40. package/dist/jsx/components/DataTable/DataTableCardControls.native.js.map +1 -1
  41. package/dist/jsx/components/DataTable/DataTableRoot.mjs +53 -10
  42. package/dist/jsx/components/DataTable/DataTableRoot.mjs.map +1 -1
  43. package/dist/jsx/components/DataTable/DataTableRoot.native.js +53 -9
  44. package/dist/jsx/components/DataTable/DataTableRoot.native.js.map +1 -1
  45. package/dist/jsx/components/DataTableFilterButton/index.mjs +14 -1
  46. package/dist/jsx/components/DataTableFilterButton/index.mjs.map +1 -1
  47. package/dist/jsx/components/DataTableFilterButton/index.native.js +15 -0
  48. package/dist/jsx/components/DataTableFilterButton/index.native.js.map +1 -1
  49. package/dist/jsx/components/DataTableToolbar/index.mjs +180 -104
  50. package/dist/jsx/components/DataTableToolbar/index.mjs.map +1 -1
  51. package/dist/jsx/components/DataTableToolbar/index.native.js +187 -109
  52. package/dist/jsx/components/DataTableToolbar/index.native.js.map +1 -1
  53. package/dist/jsx/hooks/useDataTable.mjs +32 -1
  54. package/dist/jsx/hooks/useDataTable.mjs.map +1 -1
  55. package/dist/jsx/hooks/useDataTable.native.js +37 -1
  56. package/dist/jsx/hooks/useDataTable.native.js.map +1 -1
  57. package/package.json +14 -14
  58. package/src/components/DataTable/DataTable.stories.tsx +59 -0
  59. package/src/components/DataTable/DataTableCardControls.stories.tsx +61 -2
  60. package/src/components/DataTable/DataTableCardControls.tsx +92 -66
  61. package/src/components/DataTable/DataTableRoot.tsx +82 -14
  62. package/src/components/DataTable/cardLayout.spec.tsx +4 -3
  63. package/src/components/DataTable/cardToolbar.spec.tsx +238 -0
  64. package/src/components/DataTable/chromeInterplay.spec.tsx +7 -5
  65. package/src/components/DataTable/columnWidthLayout.spec.tsx +189 -0
  66. package/src/components/DataTable/dataTableCardControls.spec.tsx +3 -2
  67. package/src/components/DataTable/footerLayout.spec.tsx +250 -0
  68. package/src/components/DataTable/pinningGeometry.spec.tsx +66 -1
  69. package/src/components/DataTableExport/DataTableExport.spec.tsx +42 -18
  70. package/src/components/DataTableFilterButton/index.tsx +9 -0
  71. package/src/components/DataTableToolbar/index.tsx +184 -105
  72. package/src/hooks/useDataTable.spec.ts +114 -0
  73. package/src/hooks/useDataTable.ts +23 -1
  74. package/src/types.ts +14 -12
  75. package/types/components/DataTable/DataTableCardControls.d.ts +3 -0
  76. package/types/components/DataTable/DataTableCardControls.d.ts.map +1 -1
  77. package/types/components/DataTable/DataTableRoot.d.ts.map +1 -1
  78. package/types/components/DataTableFilterButton/index.d.ts +2 -1
  79. package/types/components/DataTableFilterButton/index.d.ts.map +1 -1
  80. package/types/components/DataTableToolbar/index.d.ts +1 -0
  81. package/types/components/DataTableToolbar/index.d.ts.map +1 -1
  82. package/types/hooks/useDataTable.d.ts.map +1 -1
  83. package/types/types.d.ts +14 -12
  84. package/types/types.d.ts.map +1 -1
@@ -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>
@@ -283,11 +283,12 @@ describe("DataTable card layout", () => {
283
283
  expect(titles).toEqual(["John"]);
284
284
  });
285
285
 
286
- it("renders the filter chrome (Add Filter) in card layout", () => {
287
- const { getByText } = renderWithProviders(
286
+ it("renders the canonical Filter in card layout", () => {
287
+ const { getByRole, queryByText } = renderWithProviders(
288
288
  <DataTable data={sampleData} columns={columns} onStateChange={vi.fn()} />,
289
289
  );
290
- expect(getByText("Add Filter")).toBeDefined();
290
+ expect(getByRole("button", { name: "Filter" })).toBeDefined();
291
+ expect(queryByText("Add Filter")).toBeNull();
291
292
  });
292
293
 
293
294
  it("hides sort/filter chrome when sorting and filtering are disabled", () => {
@@ -0,0 +1,238 @@
1
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
2
+ import { fireEvent, screen, waitFor, within } from "@testing-library/react";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import type { DataTableColumn, FilterableField } from "../../types";
5
+ import { DataTableCardToolbar, DataTableToolbar } from "../DataTableToolbar";
6
+ import { DataTableCardSortControl } from "./DataTableCardControls";
7
+ import { useDataTableState } from "./DataTableStateContext";
8
+ import { DataTable } from "./index";
9
+
10
+ const filings = [
11
+ { id: "a", form: "Alpha", entity: "personal", year: 2023 },
12
+ { id: "b", form: "Beta", entity: "business", year: 2024 },
13
+ { id: "c", form: "Gamma", entity: "personal", year: 2023 },
14
+ ];
15
+ const columns: DataTableColumn<(typeof filings)[number]>[] = [
16
+ { accessorKey: "form", header: "Form" },
17
+ { accessorKey: "entity", header: "Entity" },
18
+ ];
19
+ const filterableFields: FilterableField[] = [{ id: "year", label: "Year", type: "number" }];
20
+
21
+ function Cards(props: Record<string, any>) {
22
+ return (
23
+ <DataTable
24
+ data={filings}
25
+ columns={columns}
26
+ filterableFields={filterableFields}
27
+ layout="cards"
28
+ enableGlobalSearch={false}
29
+ onStateChange={vi.fn()}
30
+ {...props}
31
+ />
32
+ );
33
+ }
34
+
35
+ function cardTitles(container: HTMLElement) {
36
+ return [...container.querySelectorAll('[data-card-slot="title"]')].map((el) => el.textContent);
37
+ }
38
+
39
+ function AllCardActionsToolbar(props: Record<string, any>) {
40
+ const { table } = useDataTableState()!;
41
+ return (
42
+ <DataTableCardToolbar
43
+ {...props}
44
+ enableExport
45
+ enableViews
46
+ isFetching
47
+ customActions={<DataTableCardSortControl table={table} contained />}
48
+ />
49
+ );
50
+ }
51
+
52
+ describe("default card toolbar", () => {
53
+ it("owns Filter and Sort in one primary row with no duplicate controls", () => {
54
+ const { container } = renderWithProviders(<Cards />);
55
+ const toolbar = screen.getByRole("toolbar", { name: "Table toolbar" });
56
+ expect(screen.getAllByRole("toolbar")).toHaveLength(1);
57
+ const primary = container.querySelector<HTMLElement>("[data-table-card-primary]")!;
58
+ expect(toolbar.contains(primary)).toBe(true);
59
+ expect(primary.contains(screen.getByRole("button", { name: "Filter" }))).toBe(true);
60
+ expect(primary.contains(screen.getByLabelText("Sort rows"))).toBe(true);
61
+ expect(screen.queryByText("Add Filter")).toBeNull();
62
+ });
63
+
64
+ it("keeps search and secondary view/fetching controls outside the primary pair", () => {
65
+ const { container } = renderWithProviders(<Cards enableGlobalSearch enableViews isFetching />);
66
+ const primary = container.querySelector<HTMLElement>("[data-table-card-primary]")!;
67
+ const secondary = container.querySelector<HTMLElement>("[data-table-card-secondary]")!;
68
+ expect(primary).not.toBeNull();
69
+ expect(secondary).not.toBeNull();
70
+ expect(primary.contains(screen.getByLabelText("Search table data"))).toBe(false);
71
+ expect(secondary.contains(screen.getByLabelText("Save view"))).toBe(true);
72
+ expect(secondary.querySelector("[data-table-fetching]")).not.toBeNull();
73
+ expect(primary.contains(secondary)).toBe(false);
74
+ });
75
+
76
+ it("keeps a sort-only toolbar when search, filtering and views are all off", () => {
77
+ renderWithProviders(<Cards enableFiltering={false} enableViews={false} />);
78
+ const toolbar = screen.getByRole("toolbar", { name: "Table toolbar" });
79
+ expect(within(toolbar).getByLabelText("Sort rows")).toBeTruthy();
80
+ expect(screen.queryByRole("button", { name: "Filter" })).toBeNull();
81
+ });
82
+
83
+ it("does not create an empty toolbar for unsortable columns", () => {
84
+ renderWithProviders(
85
+ <Cards
86
+ enableFiltering={false}
87
+ columns={columns.map((column) => ({ ...column, enableSorting: false }))}
88
+ />,
89
+ );
90
+ expect(screen.queryByRole("toolbar")).toBeNull();
91
+ });
92
+
93
+ it("exposes the full active sorting identity without replacing the button label recipe", () => {
94
+ const longHeader = "Form with a deliberately long distinguishing classification suffix";
95
+ renderWithProviders(
96
+ <Cards
97
+ columns={[{ accessorKey: "form", header: longHeader }]}
98
+ initialState={{ sorting: [{ id: "form", desc: true }] }}
99
+ />,
100
+ );
101
+ const trigger = screen.getByLabelText("Sort rows");
102
+ expect(trigger.getAttribute("aria-description")).toBe(`${longHeader} ↓`);
103
+ expect(trigger.textContent).toContain(`${longHeader} ↓`);
104
+ });
105
+
106
+ it("retains hidden Year filtering, chip edit and removal in readOnly cards", async () => {
107
+ const { container } = renderWithProviders(<Cards readOnly />);
108
+ expect(screen.queryByText("Add Filter")).toBeNull();
109
+ fireEvent.click(screen.getByRole("button", { name: "Filter" }));
110
+ fireEvent.click(await screen.findByRole("option", { name: "Year" }));
111
+ fireEvent.change(await screen.findByPlaceholderText("Enter value..."), {
112
+ target: { value: "2023" },
113
+ });
114
+ await waitFor(() => expect(cardTitles(container)).toEqual(["Alpha", "Gamma"]));
115
+ fireEvent.keyDown(document, { key: "Escape" });
116
+ await waitFor(() => expect(screen.queryByPlaceholderText("Enter value...")).toBeNull());
117
+ fireEvent.click(await screen.findByRole("button", { name: /Edit filter.*Year/ }));
118
+ const value = await screen.findByPlaceholderText("Enter value...");
119
+ expect((value as HTMLInputElement).value).toBe("2023");
120
+ fireEvent.change(value, { target: { value: "2024" } });
121
+ await waitFor(() => expect(cardTitles(container)).toEqual(["Beta"]));
122
+ fireEvent.keyDown(document, { key: "Escape" });
123
+ await waitFor(() => expect(screen.queryByPlaceholderText("Enter value...")).toBeNull());
124
+ fireEvent.click(screen.getByRole("button", { name: "Remove" }));
125
+ await waitFor(() => expect(cardTitles(container)).toEqual(["Alpha", "Beta", "Gamma"]));
126
+ });
127
+
128
+ it("keeps the custom-toolbar fallback and clears its active filter", async () => {
129
+ const { container } = renderWithProviders(
130
+ <Cards
131
+ renderToolbar={() => <div>Custom actions</div>}
132
+ initialState={{
133
+ filters: [{ id: "f", columnId: "form", value: { operator: "contains", value: "Alpha" } }],
134
+ }}
135
+ />,
136
+ );
137
+ expect(screen.getByText("Custom actions")).toBeTruthy();
138
+ const fallback = screen.getByRole("toolbar", { name: "Card list controls" });
139
+ expect(within(fallback).getByRole("button", { name: /Add Filter/ })).toBeTruthy();
140
+ expect(cardTitles(container)).toEqual(["Alpha"]);
141
+ fireEvent.click(within(fallback).getByRole("button", { name: "Clear" }));
142
+ await waitFor(() => expect(cardTitles(container)).toEqual(["Alpha", "Beta", "Gamma"]));
143
+ fireEvent.click(within(fallback).getByRole("button", { name: "Add Filter" }));
144
+ expect(await screen.findByText("Select column")).toBeTruthy();
145
+ });
146
+
147
+ it("leaves an explicitly supplied canonical custom toolbar intact", () => {
148
+ renderWithProviders(<Cards renderToolbar={(props: any) => <DataTableToolbar {...props} />} />);
149
+ expect(screen.getByRole("button", { name: "Filter" })).toBeTruthy();
150
+ expect(screen.getByRole("button", { name: "Add Filter" })).toBeTruthy();
151
+ expect(screen.getByRole("toolbar", { name: "Card list controls" })).toBeTruthy();
152
+ });
153
+
154
+ it("partitions actual export, long saved view, save and fetching from Filter and Sort", () => {
155
+ const name = "Saved view with a deliberately long distinguishing classification suffix";
156
+ const { container } = renderWithProviders(
157
+ <Cards
158
+ enableGlobalSearch
159
+ initialState={{
160
+ activeViewId: "long",
161
+ views: [
162
+ {
163
+ id: "long",
164
+ name,
165
+ columns: {},
166
+ filters: [],
167
+ sorting: [],
168
+ pagination: { pageIndex: 0, pageSize: 10 },
169
+ },
170
+ ],
171
+ }}
172
+ renderToolbar={(props: any) => <AllCardActionsToolbar {...props} />}
173
+ />,
174
+ );
175
+ const primary = container.querySelector<HTMLElement>("[data-table-card-primary]")!;
176
+ const secondary = container.querySelector<HTMLElement>("[data-table-card-secondary]")!;
177
+ expect(within(primary).getByLabelText("Sort rows")).toBeTruthy();
178
+ expect(within(primary).getByRole("button", { name: "Filter" })).toBeTruthy();
179
+ expect(within(secondary).getByRole("button", { name: /Export as CSV/ })).toBeTruthy();
180
+ expect(within(secondary).getByLabelText("Saved views").textContent).toContain(name);
181
+ expect(within(secondary).getByLabelText("Save view")).toBeTruthy();
182
+ expect(secondary.querySelector("[data-table-fetching]")).not.toBeNull();
183
+ expect(within(primary).queryByLabelText("Saved views")).toBeNull();
184
+ const sort = primary.querySelector("[data-table-card-sort]")!;
185
+ expect(sort.contains(within(primary).getByLabelText("Sort rows"))).toBe(true);
186
+ });
187
+
188
+ it("retains hidden filters and sort order when cards change to grid and back", () => {
189
+ const initialState = {
190
+ filters: [{ id: "year", columnId: "year", value: { operator: "is", value: "2023" } }],
191
+ sorting: [{ id: "form", desc: true }],
192
+ };
193
+ const { container, rerender } = renderWithProviders(<Cards initialState={initialState} />);
194
+ expect(cardTitles(container)).toEqual(["Gamma", "Alpha"]);
195
+ rerender(<Cards initialState={initialState} layout="table" />);
196
+ expect(cardTitles(container)).toEqual([]);
197
+ expect(screen.queryByLabelText("Sort rows")).toBeNull();
198
+ expect(screen.getByText("Gamma")).toBeTruthy();
199
+ expect(screen.queryByText("Beta")).toBeNull();
200
+ rerender(<Cards initialState={initialState} />);
201
+ expect(cardTitles(container)).toEqual(["Gamma", "Alpha"]);
202
+ expect(screen.getByLabelText("Sort rows").getAttribute("aria-description")).toBe("Form ↓");
203
+ expect(screen.getAllByRole("button", { name: "Filter" })).toHaveLength(1);
204
+ expect(screen.queryByPlaceholderText("Enter value...")).toBeNull();
205
+ });
206
+
207
+ it("clears hidden and visible advanced filters together without clearing plain column filters", async () => {
208
+ const { container } = renderWithProviders(
209
+ <Cards
210
+ initialState={{
211
+ filters: [
212
+ { id: "year", columnId: "year", value: { operator: "is", value: "2023" } },
213
+ { id: "form", columnId: "form", value: { operator: "contains", value: "Alpha" } },
214
+ ],
215
+ columnFilters: [{ id: "entity", value: "personal" }],
216
+ }}
217
+ />,
218
+ );
219
+ expect(cardTitles(container)).toEqual(["Alpha"]);
220
+ fireEvent.click(screen.getByRole("button", { name: "Clear all" }));
221
+ await waitFor(() => expect(cardTitles(container)).toEqual(["Alpha", "Gamma"]));
222
+ expect(screen.queryByRole("button", { name: /Edit filter/ })).toBeNull();
223
+ });
224
+
225
+ it("leaves the public standalone toolbar in its original layout", () => {
226
+ const { container } = renderWithProviders(
227
+ <DataTableToolbar
228
+ enableGlobalSearch
229
+ enableViews
230
+ customActions={<button type="button">Custom action</button>}
231
+ />,
232
+ );
233
+ expect(container.querySelector<HTMLElement>("[data-table-card-primary]")).toBeNull();
234
+ expect(container.querySelector<HTMLElement>("[data-table-card-secondary]")).toBeNull();
235
+ expect(screen.getByLabelText("Search table data")).toBeTruthy();
236
+ expect(screen.getByText("Custom action")).toBeTruthy();
237
+ });
238
+ });
@@ -204,8 +204,11 @@ describe("chrome interplay: Search × injected filters × enableFiltering", () =
204
204
  expect(screen.getByLabelText("Search table data")).toBeInTheDocument();
205
205
  });
206
206
 
207
- it("enableFiltering=false suppresses the card layout's Add Filter control too", () => {
207
+ it("enableFiltering=false suppresses the card layout's filter control too", () => {
208
208
  // auto layout renders cards below sm (the test env default).
209
+ // MPO-300: the default card path owns the canonical all-field Filter in
210
+ // the toolbar; the lower Card list controls toolbar and its Add Filter
211
+ // builder only mount behind a custom toolbar now.
209
212
  const control = renderWithProviders(
210
213
  <DataTable
211
214
  data={rows}
@@ -214,10 +217,8 @@ describe("chrome interplay: Search × injected filters × enableFiltering", () =
214
217
  initialState={{ filters: injectedReadyFilter }}
215
218
  />,
216
219
  );
217
- // The control renders its active-filter count in the label text
218
- // ("Add Filter (1)"), so an exact getByText no longer matches. The
219
- // aria-label is the stable handle, as the sibling arms above use.
220
- expect(screen.getByLabelText("Add Filter")).toBeInTheDocument();
220
+ expect(screen.getByRole("button", { name: "Filter" })).toBeInTheDocument();
221
+ expect(screen.queryByText("Add Filter")).not.toBeInTheDocument();
221
222
  control.unmount();
222
223
 
223
224
  renderWithProviders(
@@ -228,6 +229,7 @@ describe("chrome interplay: Search × injected filters × enableFiltering", () =
228
229
  initialState={{ filters: injectedReadyFilter }}
229
230
  />,
230
231
  );
232
+ expect(screen.queryByRole("button", { name: "Filter" })).not.toBeInTheDocument();
231
233
  expect(screen.queryByText("Add Filter")).not.toBeInTheDocument();
232
234
  // Injected filters still scope the card list.
233
235
  expect(screen.getByText("Alpha")).toBeInTheDocument();
@@ -11,12 +11,34 @@
11
11
  * render. The combination warns instead, naming `columnLayout` as the reason.
12
12
  */
13
13
 
14
+ import { fireEvent, waitFor } from "@testing-library/react";
14
15
  import { renderWithProviders } from "@multiplatform.one/test-utils";
15
16
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
17
  import type { DataTableColumn } from "../../types";
17
18
  import { __resetTableDevWarnSeen, warnColumnWidthIgnored } from "../../shared/devWarn";
18
19
  import { DataTable } from "./index";
19
20
 
21
+ vi.mock("@tanstack/react-virtual", async (importOriginal) => {
22
+ const actual = await importOriginal<typeof import("@tanstack/react-virtual")>();
23
+ return {
24
+ ...actual,
25
+ useVirtualizer: (options: Parameters<typeof actual.useVirtualizer>[0]) =>
26
+ actual.useVirtualizer({
27
+ ...options,
28
+ initialRect: { width: 800, height: 240 },
29
+ observeElementRect: (_instance, callback) => {
30
+ callback({ width: 800, height: 240 });
31
+ return () => {};
32
+ },
33
+ observeElementOffset: (_instance, callback) => {
34
+ callback(0, false);
35
+ return () => {};
36
+ },
37
+ scrollToFn: () => {},
38
+ }),
39
+ };
40
+ });
41
+
20
42
  type Pokemon = { id: number; name: string; xp: number };
21
43
 
22
44
  const sampleData: Pokemon[] = [
@@ -193,3 +215,170 @@ describe("DataTable columnLayout=equal with per-column widths", () => {
193
215
  expect(widthWarnings()).toHaveLength(0);
194
216
  });
195
217
  });
218
+
219
+ function hasSizingStyle(element: HTMLElement, property: string, value: string, token: string) {
220
+ return (
221
+ element.style.getPropertyValue(property) === value || String(element.className).includes(token)
222
+ );
223
+ }
224
+
225
+ function expectCellWidth(element: HTMLElement | null, width: number) {
226
+ expect(element).not.toBeNull();
227
+ expect(hasSizingStyle(element!, "width", `${width}px`, `_w-${width}px`)).toBe(true);
228
+ }
229
+
230
+ describe("DataTable declared column sizes", () => {
231
+ beforeEach(startCapture);
232
+ afterEach(stopCapture);
233
+
234
+ it.each(["alias", "canonical"])(
235
+ "applies %s widths to scroll headers, body and footer below the old floor",
236
+ (spelling) => {
237
+ const sizingColumns = plainColumns.map((column, index) => ({
238
+ ...column,
239
+ [spelling === "alias" ? "width" : "size"]: index === 0 ? 320 : 48,
240
+ footer: index === 0 ? "Totals" : "126",
241
+ }));
242
+ const { container } = renderTable(sizingColumns, "scroll");
243
+ expectCellWidth(container.querySelector("#name-header"), 320);
244
+ expectCellWidth(container.querySelector("#xp-header"), 48);
245
+ const cells = container.querySelectorAll('[aria-label="Table data"] [role="cell"]');
246
+ expect(cells.length).toBeGreaterThanOrEqual(4);
247
+ expectCellWidth(cells[0] as HTMLElement, 320);
248
+ expectCellWidth(cells[1] as HTMLElement, 48);
249
+ expect(hasSizingStyle(cells[1] as HTMLElement, "min-width", "0px", "_miw-0px")).toBe(true);
250
+ const footer = container.querySelectorAll('tfoot [role="cell"]');
251
+ expect(footer).toHaveLength(2);
252
+ expectCellWidth(footer[0] as HTMLElement, 320);
253
+ expectCellWidth(footer[1] as HTMLElement, 48);
254
+ expect(widthWarnings()).toHaveLength(0);
255
+ },
256
+ );
257
+
258
+ it("gives unpinned equal cells the same flex policy for canonical and alias declarations", () => {
259
+ const canonical = plainColumns.map((column, index) => ({
260
+ ...column,
261
+ size: index === 0 ? 320 : 80,
262
+ }));
263
+ const withSizes = renderTable(canonical);
264
+ const sizeMarkup = stripGeneratedIds(withSizes.container.innerHTML);
265
+ withSizes.unmount();
266
+ const withoutSizes = renderTable(plainColumns);
267
+ expect(stripGeneratedIds(withoutSizes.container.innerHTML)).toBe(sizeMarkup);
268
+ });
269
+
270
+ it("lets resizing use aliases in equal mode while preserving the fixed flex basis", () => {
271
+ const { container } = renderWithProviders(
272
+ <DataTable
273
+ data={sampleData}
274
+ columns={widthColumns}
275
+ columnLayout="equal"
276
+ layout="table"
277
+ enableColumnResizing
278
+ enablePagination={false}
279
+ readOnly
280
+ />,
281
+ );
282
+ const header = container.querySelector("#name-header") as HTMLElement;
283
+ expectCellWidth(header, 320);
284
+ expect(hasSizingStyle(header, "flex-basis", "auto", "_fb-auto")).toBe(true);
285
+ expect(hasSizingStyle(header, "flex-grow", "0", "_fg-0")).toBe(true);
286
+ expect(widthWarnings()).toHaveLength(0);
287
+ });
288
+
289
+ it("does not inflate a 48px resizable equal-layout cell with the primitive width floor", () => {
290
+ const { container } = renderWithProviders(
291
+ <DataTable
292
+ data={sampleData}
293
+ columns={[{ accessorKey: "xp", header: "XP", width: 48 }]}
294
+ columnLayout="equal"
295
+ layout="table"
296
+ enableColumnResizing
297
+ enablePagination={false}
298
+ readOnly
299
+ />,
300
+ );
301
+ const header = container.querySelector("#xp-header") as HTMLElement;
302
+ expectCellWidth(header, 48);
303
+ expect(hasSizingStyle(header, "min-width", "0px", "_miw-0px")).toBe(true);
304
+ });
305
+
306
+ it("passes size inputs to auto layout without changing its table algorithm", () => {
307
+ const { container } = renderTable(widthColumns, "auto");
308
+ expectCellWidth(container.querySelector("#name-header"), 320);
309
+ expect((container.querySelector('[role="table"]') as HTMLElement).style.tableLayout).toBe(
310
+ "fixed",
311
+ );
312
+ });
313
+
314
+ it("resizes an aliased column through the real header handle within its bounds", () => {
315
+ const columns = widthColumns.map((column) => ({ ...column, minWidth: 50, maxWidth: 350 }));
316
+ const { container, getByRole } = renderWithProviders(
317
+ <DataTable
318
+ data={sampleData}
319
+ columns={columns}
320
+ columnLayout="scroll"
321
+ layout="table"
322
+ enableColumnResizing
323
+ enablePagination={false}
324
+ readOnly
325
+ />,
326
+ );
327
+ const handle = getByRole("separator", { name: "Resize Name column" });
328
+ expect(handle.getAttribute("aria-valuenow")).toBe("320");
329
+ fireEvent.mouseDown(handle, { clientX: 100 });
330
+ fireEvent.mouseMove(document, { clientX: 120 });
331
+ fireEvent.mouseUp(document, { clientX: 120 });
332
+ expectCellWidth(container.querySelector("#name-header"), 340);
333
+ fireEvent.mouseDown(handle, { clientX: 100 });
334
+ fireEvent.mouseMove(document, { clientX: 300 });
335
+ fireEvent.mouseUp(document, { clientX: 300 });
336
+ expectCellWidth(container.querySelector("#name-header"), 350);
337
+ expect(handle.getAttribute("aria-valuenow")).toBe("350");
338
+ const cell = container.querySelector<HTMLElement>('[aria-label="Table data"] [role="cell"]');
339
+ expectCellWidth(cell, 350);
340
+ });
341
+
342
+ it("exposes the normalized size to the view-options display on mount", async () => {
343
+ const { getByText } = renderWithProviders(
344
+ <DataTable
345
+ data={sampleData}
346
+ columns={widthColumns}
347
+ columnLayout="scroll"
348
+ layout="table"
349
+ enableColumnResizing
350
+ enablePagination={false}
351
+ readOnly
352
+ >
353
+ <DataTable.ViewOptions showColumnResizing showColumnVisibility={false} />
354
+ </DataTable>,
355
+ );
356
+ await waitFor(() => expect(getByText("320px")).toBeDefined());
357
+ expect(getByText("80px")).toBeDefined();
358
+ });
359
+
360
+ it("uses the real virtual row renderer with the same declared widths", async () => {
361
+ const { container } = renderWithProviders(
362
+ <DataTable
363
+ data={sampleData}
364
+ columns={widthColumns}
365
+ columnLayout="scroll"
366
+ layout="table"
367
+ enableVirtualization
368
+ virtualizationHeight={240}
369
+ enablePagination={false}
370
+ readOnly
371
+ />,
372
+ );
373
+ await waitFor(() => {
374
+ const virtualBody = container.querySelector('tbody[role="presentation"] [role="rowgroup"]');
375
+ expect(virtualBody).not.toBeNull();
376
+ expect(virtualBody!.querySelectorAll('[role="row"]')).toHaveLength(2);
377
+ const cells = virtualBody!.querySelectorAll('[role="cell"]');
378
+ expect(cells.length).toBeGreaterThanOrEqual(4);
379
+ expectCellWidth(cells[0] as HTMLElement, 320);
380
+ expectCellWidth(cells[1] as HTMLElement, 80);
381
+ });
382
+ expectCellWidth(container.querySelector("#name-header"), 320);
383
+ });
384
+ });