@multiplatform.one/table 7.6.3 → 7.7.1

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 (26) hide show
  1. package/package.json +12 -13
  2. package/src/components/ChildTable/ChildTable.stories.tsx +4 -1
  3. package/src/components/DataTable/DataTable.spec.tsx +13 -7
  4. package/src/components/DataTable/DataTableCardControls.stories.tsx +116 -0
  5. package/src/components/DataTable/DataTableDocumentList.stories.tsx +132 -0
  6. package/src/components/DataTable/DataTableMobileCards.stories.tsx +124 -0
  7. package/src/components/DataTable/DataTableVirtualized.stories.tsx +69 -0
  8. package/src/components/DataTable/chromeInterplay.spec.tsx +4 -1
  9. package/src/components/DataTable/dataTableCardControls.spec.tsx +99 -0
  10. package/src/components/DataTable/dataTableDocumentList.spec.tsx +143 -0
  11. package/src/components/DataTable/dataTableMobileCards.spec.tsx +151 -0
  12. package/src/components/DataTableCell/CellOptionsEditor.stories.tsx +139 -0
  13. package/src/components/DataTableCell/cellOptionsEditor.spec.tsx +127 -0
  14. package/src/components/DataTableExport/DataTableExport.spec.tsx +5 -0
  15. package/src/components/DataTableFilterButton/DataTableFilterButton.spec.tsx +128 -0
  16. package/src/components/DataTableFilterButton/DataTableFilterButton.stories.tsx +99 -0
  17. package/src/components/DataTableFilterChips/DataTableFilterChips.spec.tsx +6 -81
  18. package/src/components/DataTableFilterChips/DataTableFilterChips.stories.tsx +105 -0
  19. package/src/components/DataTableFilterChips/FilterChip.spec.tsx +77 -0
  20. package/src/components/DataTableFilterChips/FilterChip.stories.tsx +111 -0
  21. package/src/components/FilterManager.stories.tsx +75 -0
  22. package/src/components/FilterOperatorValueEditor/FilterOperatorValueEditor.spec.tsx +261 -0
  23. package/src/components/FilterOperatorValueEditor/FilterOperatorValueEditor.stories.tsx +183 -0
  24. package/src/components/KnobDialog.spec.tsx +88 -0
  25. package/src/components/KnobDialog.stories.tsx +66 -0
  26. package/src/components/SavedFilterDropdown.stories.tsx +90 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/table",
3
- "version": "7.6.3",
3
+ "version": "7.7.1",
4
4
  "description": "Advanced table components with filtering, pagination, sorting, and URL state management",
5
5
  "keywords": [
6
6
  "cross-platform",
@@ -80,18 +80,17 @@
80
80
  "@tanstack/react-table": "^8.21.3",
81
81
  "@tanstack/table-core": "^8.21.3",
82
82
  "tamagui": "2.7.6",
83
- "@multiplatform.one/forms": "7.6.3",
84
- "@multiplatform.one/components": "7.6.3",
85
- "@multiplatform.one/platform": "7.6.3",
86
- "@multiplatform.one/rich-text": "7.6.3",
87
- "@multiplatform.one/router": "7.6.3",
88
- "@multiplatform.one/table-primitives": "7.6.3",
89
- "@multiplatform.one/theme": "7.6.3"
83
+ "@multiplatform.one/components": "7.7.1",
84
+ "@multiplatform.one/forms": "7.7.1",
85
+ "@multiplatform.one/platform": "7.7.1",
86
+ "@multiplatform.one/rich-text": "7.7.1",
87
+ "@multiplatform.one/router": "7.7.1",
88
+ "@multiplatform.one/table-primitives": "7.7.1",
89
+ "@multiplatform.one/theme": "7.7.1"
90
90
  },
91
91
  "devDependencies": {
92
92
  "@tamagui/build": "2.7.6",
93
93
  "@testing-library/react": "^16.3.2",
94
- "@types/react": "~19.2.14",
95
94
  "@types/react-dom": "^19.2.3",
96
95
  "@vitejs/plugin-react": "^6.0.1",
97
96
  "@vitest/coverage-v8": "^4.1.5",
@@ -99,10 +98,10 @@
99
98
  "react": "19.2.5",
100
99
  "react-dom": "19.2.5",
101
100
  "vitest": "^4.1.5",
102
- "@multiplatform.one/config": "7.6.3",
103
- "@multiplatform.one/router": "7.6.3",
104
- "@multiplatform.one/storybook": "7.6.3",
105
- "@multiplatform.one/test-utils": "7.6.3"
101
+ "@multiplatform.one/config": "7.7.1",
102
+ "@multiplatform.one/router": "7.7.1",
103
+ "@multiplatform.one/storybook": "7.7.1",
104
+ "@multiplatform.one/test-utils": "7.7.1"
106
105
  },
107
106
  "peerDependencies": {
108
107
  "i18next": "^25.0.0",
@@ -30,7 +30,10 @@ const columns: DataTableColumn<LineItem>[] = [
30
30
  { accessorKey: "qty", header: "Qty", field: "stepper", fieldProps: { min: 0 } },
31
31
  ];
32
32
 
33
- const meta: Meta<typeof ChildTable> = {
33
+ // Instantiation expression: `Meta<typeof ChildTable>` resolves ChildTable's
34
+ // TRow to its default (DataTableData), so `args.columns` typed as
35
+ // DataTableColumn<DataTableData>[] and rejected the LineItem columns below.
36
+ const meta: Meta<typeof ChildTable<LineItem>> = {
34
37
  title: "Table/ChildTable",
35
38
  component: ChildTable,
36
39
  parameters: {
@@ -328,10 +328,11 @@ describe("DataTable", () => {
328
328
  expect(screen.getByText("John")).toBeDefined();
329
329
  expect(screen.getByText("Jane")).toBeDefined();
330
330
 
331
- // The forms SearchInput default placeholder is bidi-isolated (LC-58,
332
- // FSI…PDI) — match the isolated form, same as the DataTablePagination
333
- // range-label assertions.
334
- await user.type(screen.getByPlaceholderText("\u2068Search...\u2069"), "Charlie");
331
+ // DataTableToolbar passes an explicit `placeholder={t("Search")}`, so
332
+ // the forms SearchInput default (bidi-isolated "Search…") never reaches
333
+ // this input. Query the stable aria-label instead of a literal
334
+ // placeholder string.
335
+ await user.type(screen.getByLabelText("Search table data"), "Charlie");
335
336
 
336
337
  await waitFor(() => {
337
338
  expect(screen.queryByText("John")).toBeNull();
@@ -656,7 +657,10 @@ describe("DataTable", () => {
656
657
  );
657
658
 
658
659
  expect(screen.queryByLabelText("Select all rows")).toBeNull();
659
- expect(screen.queryByLabelText(/^Select row/)).toBeNull();
660
+ // Anchored on the row-name template, not the bare "Select row" prefix:
661
+ // DataTablePagination's page-size control is labelled "Select rows per
662
+ // page", which /^Select row/ also matches.
663
+ expect(screen.queryByLabelText(/^Select row \S/)).toBeNull();
660
664
  expect(screen.queryByText(/selected/)).toBeNull();
661
665
  });
662
666
 
@@ -673,8 +677,10 @@ describe("DataTable", () => {
673
677
 
674
678
  expect(screen.getByLabelText("Select all rows")).toBeDefined();
675
679
  // Row identity is templated into the name (DG-A11Y-06); without an
676
- // initialized i18n instance the raw key renders, so match the prefix.
677
- expect(screen.getAllByLabelText(/^Select row/).length).toBe(sampleData.length);
680
+ // initialized i18n instance the raw key renders, so match the prefix —
681
+ // but require a following token so the pagination page-size control
682
+ // ("Select rows per page") is not counted as a sixth row.
683
+ expect(screen.getAllByLabelText(/^Select row \S/).length).toBe(sampleData.length);
678
684
  });
679
685
 
680
686
  it("auto-shows selection toolbar with count and clear after selecting a row", async () => {
@@ -0,0 +1,116 @@
1
+ /**
2
+ * DataTableCardControls — the sort + filter row above the card list.
3
+ *
4
+ * The grid puts sorting and per-column filtering in the column headers. The
5
+ * card layout has no headers, so this toolbar restores both: a sort menu that
6
+ * cycles none → asc → desc per sortable column (the same cycle as a header
7
+ * click) and the compact DataTableFilters chrome. Both drive the same table
8
+ * state as the grid — there is no parallel state.
9
+ *
10
+ * It only exists in `layout="cards"`, which is why these stories pin the
11
+ * layout instead of relying on the viewport.
12
+ */
13
+
14
+ import type { Meta } from "@storybook/react-native-web-vite";
15
+ import { Paragraph, YStack } from "tamagui";
16
+ import type { DataTableColumn } from "../../types";
17
+ import { DataTable } from "./index";
18
+
19
+ const meta: Meta = {
20
+ title: "Table/DataTableCardControls",
21
+ parameters: {
22
+ status: { type: "stable" },
23
+ docs: {
24
+ description: {
25
+ component:
26
+ "Sort menu plus filter chrome above the card list, standing in for the grid's " +
27
+ "column headers. Renders only in the cards layout, and only when sorting or " +
28
+ "filtering is enabled.",
29
+ },
30
+ },
31
+ },
32
+ };
33
+
34
+ export default meta;
35
+
36
+ interface Person {
37
+ id: number;
38
+ firstName: string;
39
+ lastName: string;
40
+ age: number;
41
+ email: string;
42
+ }
43
+
44
+ const people: Person[] = [
45
+ { id: 1, firstName: "John", lastName: "Doe", age: 28, email: "john@example.com" },
46
+ { id: 2, firstName: "Jane", lastName: "Smith", age: 32, email: "jane@example.com" },
47
+ { id: 3, firstName: "Bob", lastName: "Johnson", age: 45, email: "bob@example.com" },
48
+ { id: 4, firstName: "Alice", lastName: "Williams", age: 22, email: "alice@example.com" },
49
+ ];
50
+
51
+ const columns: DataTableColumn<Person>[] = [
52
+ { accessorKey: "firstName", header: "First Name" },
53
+ { accessorKey: "lastName", header: "Last Name" },
54
+ { accessorKey: "age", header: "Age" },
55
+ { accessorKey: "email", header: "Email" },
56
+ ];
57
+
58
+ function CardTable(props: Record<string, any>) {
59
+ return (
60
+ <YStack padding="$4" width="100%" maxWidth={520}>
61
+ <DataTable
62
+ data={people}
63
+ columns={columns}
64
+ layout="cards"
65
+ aria-label="People as cards"
66
+ {...props}
67
+ />
68
+ </YStack>
69
+ );
70
+ }
71
+
72
+ /** Both affordances: Sort menu on the left, Add Filter chrome beside it. */
73
+ export const Default = {
74
+ name: "Main",
75
+ render: () => <CardTable enableSorting enableFiltering />,
76
+ };
77
+
78
+ /** Sorting only — no filter chrome in the row. */
79
+ export const SortOnly = {
80
+ render: () => <CardTable enableSorting enableFiltering={false} />,
81
+ };
82
+
83
+ /** Filtering only — no Sort menu in the row. */
84
+ export const FilterOnly = {
85
+ render: () => <CardTable enableSorting={false} enableFiltering />,
86
+ };
87
+
88
+ /**
89
+ * With a sort already applied the button reads the column and direction
90
+ * (`Age ↑`) instead of the bare "Sort", so the card list is never sorted
91
+ * invisibly.
92
+ */
93
+ export const ActiveSort = {
94
+ render: () => (
95
+ <CardTable
96
+ enableSorting
97
+ enableFiltering
98
+ initialState={{ sorting: [{ id: "age", desc: false }] }}
99
+ />
100
+ ),
101
+ };
102
+
103
+ /** UX-011: the whole table disabled renders the controls inert and LC-40 visible. */
104
+ export const Disabled = {
105
+ render: () => <CardTable enableSorting enableFiltering disabled />,
106
+ };
107
+
108
+ /** Neither enabled: the toolbar is absent, not an empty bar taking vertical space. */
109
+ export const NoControls = {
110
+ render: () => (
111
+ <YStack padding="$4" gap="$2" width="100%" maxWidth={520}>
112
+ <Paragraph size="$2">Sorting and filtering off — no control row above the cards.</Paragraph>
113
+ <CardTable enableSorting={false} enableFiltering={false} />
114
+ </YStack>
115
+ ),
116
+ };
@@ -0,0 +1,132 @@
1
+ /**
2
+ * DataTableDocumentList — subject-led document rows for `layout="list"`.
3
+ *
4
+ * This is the desk List view, not a grid and not cards. Each document gets one
5
+ * horizontal row: checkbox, then a title with its meta stacked underneath in
6
+ * muted text, then a trailing indicator cluster, then actions. No column
7
+ * headers, no key-value labels, no card frame, and no wrapping into a column
8
+ * grid (Polaris Index / Frappe list).
9
+ *
10
+ * Slots are assigned by column, not by position: `meta.role` of `title`,
11
+ * `indicator`, `actions` or `hidden` decides, and status-shaped ids
12
+ * (status, priority, workflow_state, state) and option-group fields fall into
13
+ * the indicator cluster on their own.
14
+ */
15
+
16
+ import type { Meta } from "@storybook/react-native-web-vite";
17
+ import { YStack } from "tamagui";
18
+ import type { DataTableColumn } from "../../types";
19
+ import { DataTable } from "./index";
20
+
21
+ const meta: Meta = {
22
+ title: "Table/DataTableDocumentList",
23
+ parameters: {
24
+ status: { type: "stable" },
25
+ docs: {
26
+ description: {
27
+ component:
28
+ "One row per document: title plus stacked muted meta, a trailing indicator cluster, " +
29
+ "then actions. Stacked-group radius rounds the first and last rows only.",
30
+ },
31
+ },
32
+ },
33
+ };
34
+
35
+ export default meta;
36
+
37
+ interface Task {
38
+ id: number;
39
+ title: string;
40
+ owner: string;
41
+ due: string;
42
+ status: string;
43
+ }
44
+
45
+ const tasks: Task[] = [
46
+ {
47
+ id: 1,
48
+ title: "Ship the card layout",
49
+ owner: "Ada Lovelace",
50
+ due: "2026-09-08",
51
+ status: "Open",
52
+ },
53
+ {
54
+ id: 2,
55
+ title: "Rewire the filter chips",
56
+ owner: "Grace Hopper",
57
+ due: "2026-09-10",
58
+ status: "In progress",
59
+ },
60
+ {
61
+ id: 3,
62
+ title: "Retire the legacy grid",
63
+ owner: "Alan Turing",
64
+ due: "2026-09-15",
65
+ status: "Blocked",
66
+ },
67
+ {
68
+ id: 4,
69
+ title: "Document the slot taxonomy",
70
+ owner: "Katherine Johnson",
71
+ due: "2026-09-20",
72
+ status: "Open",
73
+ },
74
+ ];
75
+
76
+ /** Roles are explicit: title leads, status is the trailing indicator. */
77
+ const columns: DataTableColumn<Task>[] = [
78
+ { accessorKey: "title", header: "Title", meta: { role: "title" } },
79
+ { accessorKey: "owner", header: "Owner" },
80
+ { accessorKey: "due", header: "Due" },
81
+ { accessorKey: "status", header: "Status", meta: { role: "indicator" } },
82
+ ];
83
+
84
+ function ListTable(props: Record<string, any>) {
85
+ return (
86
+ <YStack padding="$4" width="100%" maxWidth={720}>
87
+ <DataTable
88
+ data={tasks}
89
+ columns={columns}
90
+ layout="list"
91
+ aria-label="Tasks as a document list"
92
+ {...props}
93
+ />
94
+ </YStack>
95
+ );
96
+ }
97
+
98
+ /** Title leads, owner and due stack beneath it, status rides the right edge. */
99
+ export const Default = {
100
+ name: "Main",
101
+ render: () => <ListTable />,
102
+ };
103
+
104
+ /** Selection puts a checkbox at the head of every row. */
105
+ export const Selection = {
106
+ render: () => <ListTable enableRowSelection />,
107
+ };
108
+
109
+ /**
110
+ * No `meta.role` anywhere: the first non-action column becomes the title and
111
+ * `status` still lands in the indicator cluster on its id alone.
112
+ */
113
+ export const InferredSlots = {
114
+ render: () => (
115
+ <ListTable
116
+ columns={[
117
+ { accessorKey: "title", header: "Title" },
118
+ { accessorKey: "owner", header: "Owner" },
119
+ { accessorKey: "status", header: "Status" },
120
+ ]}
121
+ />
122
+ ),
123
+ };
124
+
125
+ /** No rows and no filters: the list paints "No data available", not blank. */
126
+ export const Empty = {
127
+ render: () => (
128
+ <YStack padding="$4" width="100%" maxWidth={720}>
129
+ <DataTable data={[]} columns={columns} layout="list" aria-label="Empty document list" />
130
+ </YStack>
131
+ ),
132
+ };
@@ -0,0 +1,124 @@
1
+ /**
2
+ * DataTableMobileCards — the stacked card body for `layout="cards"`.
3
+ *
4
+ * Below `$sm` (or forced) a DataTable stops being a grid: each row becomes a
5
+ * card whose first visible column is the title and whose remaining columns
6
+ * become label/value rows. Column visibility, order, sorting and filtering all
7
+ * come from the same table state as the grid — the cells run through the same
8
+ * renderers inside `TableCellContext`, so fields stay chromeless.
9
+ *
10
+ * Selection, row numbers and delete live in the card header beside the title.
11
+ */
12
+
13
+ import type { Meta } from "@storybook/react-native-web-vite";
14
+ import { useState } from "react";
15
+ import { YStack } from "tamagui";
16
+ import type { DataTableColumn } from "../../types";
17
+ import { DataTable } from "./index";
18
+
19
+ const meta: Meta = {
20
+ title: "Table/DataTableMobileCards",
21
+ parameters: {
22
+ status: { type: "stable" },
23
+ docs: {
24
+ description: {
25
+ component:
26
+ "One card per row: title in the header, remaining columns as label/value rows. " +
27
+ "Select-all sits above the stack; per-row select, row number and delete sit in the card header.",
28
+ },
29
+ },
30
+ },
31
+ };
32
+
33
+ export default meta;
34
+
35
+ interface Person {
36
+ id: number;
37
+ firstName: string;
38
+ lastName: string;
39
+ age: number;
40
+ email: string;
41
+ }
42
+
43
+ const people: Person[] = [
44
+ { id: 1, firstName: "John", lastName: "Doe", age: 28, email: "john@example.com" },
45
+ { id: 2, firstName: "Jane", lastName: "Smith", age: 32, email: "jane@example.com" },
46
+ { id: 3, firstName: "Bob", lastName: "Johnson", age: 45, email: "bob@example.com" },
47
+ { id: 4, firstName: "Alice", lastName: "Williams", age: 22, email: "alice@example.com" },
48
+ ];
49
+
50
+ const columns: DataTableColumn<Person>[] = [
51
+ { accessorKey: "firstName", header: "First Name" },
52
+ { accessorKey: "lastName", header: "Last Name" },
53
+ { accessorKey: "age", header: "Age" },
54
+ { accessorKey: "email", header: "Email" },
55
+ ];
56
+
57
+ function CardTable(props: Record<string, any>) {
58
+ return (
59
+ <YStack padding="$4" width="100%" maxWidth={480}>
60
+ <DataTable
61
+ data={people}
62
+ columns={columns}
63
+ layout="cards"
64
+ aria-label="People as cards"
65
+ {...props}
66
+ />
67
+ </YStack>
68
+ );
69
+ }
70
+
71
+ /** Card anatomy: title header, then one label/value row per remaining column. */
72
+ export const Default = {
73
+ name: "Main",
74
+ render: () => <CardTable />,
75
+ };
76
+
77
+ /** Select-all above the stack, a checkbox in every card header. */
78
+ export const Selection = {
79
+ render: () => <CardTable enableRowSelection />,
80
+ };
81
+
82
+ function DeletableCards() {
83
+ const [rows, setRows] = useState(people);
84
+ return (
85
+ <YStack padding="$4" width="100%" maxWidth={480}>
86
+ <DataTable
87
+ data={rows}
88
+ columns={columns}
89
+ layout="cards"
90
+ showRowNumbers
91
+ showDeleteRow
92
+ minRows={1}
93
+ onDataChange={(next: Person[]) => setRows(next)}
94
+ aria-label="People as deletable cards"
95
+ />
96
+ </YStack>
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Row numbers and the delete affordance share the card header with the title.
102
+ * The parent owns `data` — the card asks via onDataChange, it does not splice.
103
+ */
104
+ export const RowChrome = {
105
+ render: () => <DeletableCards />,
106
+ };
107
+
108
+ /** A single visible column: the header carries the title and there are no field rows. */
109
+ export const TitleOnly = {
110
+ render: () => (
111
+ <CardTable
112
+ columns={[{ accessorKey: "firstName", header: "First Name" } as DataTableColumn<Person>]}
113
+ />
114
+ ),
115
+ };
116
+
117
+ /** No rows and no filters: the empty state reads "No data available". */
118
+ export const Empty = {
119
+ render: () => (
120
+ <YStack padding="$4" width="100%" maxWidth={480}>
121
+ <DataTable data={[]} columns={columns} layout="cards" aria-label="Empty card list" />
122
+ </YStack>
123
+ ),
124
+ };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * DataTableVirtualized is an internal body renderer. The public surface is
3
+ * DataTable with enableVirtualization — mounting the helper bare crashed
4
+ * playground SSR on virtualItems.map. This catalog row is that mode, not
5
+ * the helper's prop dump.
6
+ */
7
+
8
+ import type { Meta } from "@storybook/react-native-web-vite";
9
+ import { YStack } from "tamagui";
10
+ import type { DataTableColumn } from "../../types";
11
+ import { DataTable } from "./index";
12
+
13
+ const meta: Meta = {
14
+ title: "Table/DataTableVirtualized",
15
+ parameters: {
16
+ status: { type: "stable" },
17
+ docs: {
18
+ description: {
19
+ component:
20
+ "Virtual-scroll body for large row sets. Enable it on DataTable; do not mount " +
21
+ "DataTableVirtualized as a standalone component.",
22
+ },
23
+ },
24
+ },
25
+ };
26
+
27
+ export default meta;
28
+
29
+ interface Employee {
30
+ id: number;
31
+ name: string;
32
+ email: string;
33
+ department: string;
34
+ salary: number;
35
+ }
36
+
37
+ const departments = ["Engineering", "Marketing", "Sales", "Design", "Support"];
38
+
39
+ const rows: Employee[] = Array.from({ length: 200 }, (_, i) => ({
40
+ id: i + 1,
41
+ name: `Employee ${i + 1}`,
42
+ email: `emp${i + 1}@example.com`,
43
+ department: departments[i % departments.length],
44
+ salary: 50000 + (i % 80) * 1000,
45
+ }));
46
+
47
+ const columns: DataTableColumn<Employee>[] = [
48
+ { accessorKey: "id", header: "ID" },
49
+ { accessorKey: "name", header: "Name" },
50
+ { accessorKey: "email", header: "Email" },
51
+ { accessorKey: "department", header: "Department" },
52
+ { accessorKey: "salary", header: "Salary" },
53
+ ];
54
+
55
+ export const Default = {
56
+ name: "Main",
57
+ render: () => (
58
+ <YStack padding="$4" height={360}>
59
+ <DataTable
60
+ data={rows}
61
+ columns={columns}
62
+ enableVirtualization
63
+ virtualizationHeight={320}
64
+ enableGlobalSearch
65
+ aria-label="Virtualized employees"
66
+ />
67
+ </YStack>
68
+ ),
69
+ };
@@ -214,7 +214,10 @@ describe("chrome interplay: Search × injected filters × enableFiltering", () =
214
214
  initialState={{ filters: injectedReadyFilter }}
215
215
  />,
216
216
  );
217
- expect(screen.getByText("Add Filter")).toBeInTheDocument();
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();
218
221
  control.unmount();
219
222
 
220
223
  renderWithProviders(
@@ -0,0 +1,99 @@
1
+ /**
2
+ * DataTableCardControls specs — the control row's OWN contract.
3
+ *
4
+ * cardLayout.spec.tsx already drives the sort menu through the card list and
5
+ * proves the cycle reorders cards. What nothing pinned is the row itself: that
6
+ * it is a labelled toolbar, that it disappears entirely when neither affordance
7
+ * is enabled (rather than rendering an empty bar), that each affordance can be
8
+ * turned off independently, and that the trigger reports the ACTIVE sort so a
9
+ * card list is never sorted invisibly.
10
+ */
11
+
12
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
13
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
14
+ import { describe, expect, it, vi } from "vitest";
15
+ import type { DataTableColumn } from "../../types";
16
+ import { DataTable } from "./index";
17
+
18
+ type Person = {
19
+ id: number;
20
+ firstName: string;
21
+ age: number;
22
+ email: string;
23
+ };
24
+
25
+ const people: Person[] = [
26
+ { id: 1, firstName: "John", age: 28, email: "john@example.com" },
27
+ { id: 2, firstName: "Jane", age: 32, email: "jane@example.com" },
28
+ { id: 3, firstName: "Bob", age: 45, email: "bob@example.com" },
29
+ ];
30
+
31
+ const columns: DataTableColumn<Person>[] = [
32
+ { accessorKey: "firstName", header: "First Name" },
33
+ { accessorKey: "age", header: "Age" },
34
+ ];
35
+
36
+ function renderCards(props: Record<string, any> = {}) {
37
+ return renderWithProviders(
38
+ <DataTable data={people} columns={columns} layout="cards" onStateChange={vi.fn()} {...props} />,
39
+ );
40
+ }
41
+
42
+ describe("DataTableCardControls", () => {
43
+ it("is a labelled toolbar above the card list", () => {
44
+ renderCards({ enableSorting: true, enableFiltering: true });
45
+ const toolbar = screen.getByRole("toolbar", { name: "Card list controls" });
46
+ expect(toolbar).toBeTruthy();
47
+ expect(screen.getByLabelText("Sort rows")).toBeTruthy();
48
+ });
49
+
50
+ it("renders no toolbar at all when neither sorting nor filtering is enabled", () => {
51
+ renderCards({ enableSorting: false, enableFiltering: false });
52
+ expect(screen.queryByRole("toolbar", { name: "Card list controls" })).toBeNull();
53
+ });
54
+
55
+ it("sorting only: the sort menu is there, the filter chrome is not", () => {
56
+ renderCards({ enableSorting: true, enableFiltering: false });
57
+ expect(screen.getByLabelText("Sort rows")).toBeTruthy();
58
+ expect(screen.queryByText("Add Filter")).toBeNull();
59
+ });
60
+
61
+ it("filtering only: the filter chrome is there, the sort menu is not", () => {
62
+ renderCards({ enableSorting: false, enableFiltering: true });
63
+ expect(screen.queryByLabelText("Sort rows")).toBeNull();
64
+ expect(screen.getByText("Add Filter")).toBeTruthy();
65
+ });
66
+
67
+ it("with no sort applied the trigger reads the neutral label", () => {
68
+ renderCards({ enableSorting: true });
69
+ expect(screen.getByLabelText("Sort rows").textContent).toContain("Sort");
70
+ expect(screen.getByLabelText("Sort rows").textContent).not.toContain("Age");
71
+ });
72
+
73
+ it("an active ascending sort is named on the trigger, arrow and all", () => {
74
+ renderCards({ enableSorting: true, initialState: { sorting: [{ id: "age", desc: false }] } });
75
+ expect(screen.getByLabelText("Sort rows").textContent).toContain("Age ↑");
76
+ });
77
+
78
+ it("an active descending sort flips the arrow on the trigger", () => {
79
+ renderCards({ enableSorting: true, initialState: { sorting: [{ id: "age", desc: true }] } });
80
+ expect(screen.getByLabelText("Sort rows").textContent).toContain("Age ↓");
81
+ });
82
+
83
+ it("picking a column from the menu updates the trigger label", async () => {
84
+ renderCards({ enableSorting: true });
85
+ fireEvent.click(screen.getByLabelText("Sort rows"));
86
+ fireEvent.click(await screen.findByLabelText("Sort by Age"));
87
+ await waitFor(() => {
88
+ expect(screen.getByLabelText("Sort rows").textContent).toContain("Age ↑");
89
+ });
90
+ });
91
+
92
+ it("offers one menu entry per sortable column and no more", async () => {
93
+ renderCards({ enableSorting: true });
94
+ fireEvent.click(screen.getByLabelText("Sort rows"));
95
+ expect(await screen.findByLabelText("Sort by First Name")).toBeTruthy();
96
+ expect(screen.getByLabelText("Sort by Age")).toBeTruthy();
97
+ expect(screen.queryByLabelText("Sort by Email")).toBeNull();
98
+ });
99
+ });