@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.
- package/package.json +12 -13
- package/src/components/ChildTable/ChildTable.stories.tsx +4 -1
- package/src/components/DataTable/DataTable.spec.tsx +13 -7
- package/src/components/DataTable/DataTableCardControls.stories.tsx +116 -0
- package/src/components/DataTable/DataTableDocumentList.stories.tsx +132 -0
- package/src/components/DataTable/DataTableMobileCards.stories.tsx +124 -0
- package/src/components/DataTable/DataTableVirtualized.stories.tsx +69 -0
- package/src/components/DataTable/chromeInterplay.spec.tsx +4 -1
- package/src/components/DataTable/dataTableCardControls.spec.tsx +99 -0
- package/src/components/DataTable/dataTableDocumentList.spec.tsx +143 -0
- package/src/components/DataTable/dataTableMobileCards.spec.tsx +151 -0
- package/src/components/DataTableCell/CellOptionsEditor.stories.tsx +139 -0
- package/src/components/DataTableCell/cellOptionsEditor.spec.tsx +127 -0
- package/src/components/DataTableExport/DataTableExport.spec.tsx +5 -0
- package/src/components/DataTableFilterButton/DataTableFilterButton.spec.tsx +128 -0
- package/src/components/DataTableFilterButton/DataTableFilterButton.stories.tsx +99 -0
- package/src/components/DataTableFilterChips/DataTableFilterChips.spec.tsx +6 -81
- package/src/components/DataTableFilterChips/DataTableFilterChips.stories.tsx +105 -0
- package/src/components/DataTableFilterChips/FilterChip.spec.tsx +77 -0
- package/src/components/DataTableFilterChips/FilterChip.stories.tsx +111 -0
- package/src/components/FilterManager.stories.tsx +75 -0
- package/src/components/FilterOperatorValueEditor/FilterOperatorValueEditor.spec.tsx +261 -0
- package/src/components/FilterOperatorValueEditor/FilterOperatorValueEditor.stories.tsx +183 -0
- package/src/components/KnobDialog.spec.tsx +88 -0
- package/src/components/KnobDialog.stories.tsx +66 -0
- 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.
|
|
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/
|
|
84
|
-
"@multiplatform.one/
|
|
85
|
-
"@multiplatform.one/platform": "7.
|
|
86
|
-
"@multiplatform.one/rich-text": "7.
|
|
87
|
-
"@multiplatform.one/router": "7.
|
|
88
|
-
"@multiplatform.one/table-primitives": "7.
|
|
89
|
-
"@multiplatform.one/theme": "7.
|
|
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.
|
|
103
|
-
"@multiplatform.one/router": "7.
|
|
104
|
-
"@multiplatform.one/storybook": "7.
|
|
105
|
-
"@multiplatform.one/test-utils": "7.
|
|
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
|
-
|
|
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
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|