@multiplatform.one/table 7.7.0 → 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 -12
- 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/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/DataTableFilterChips/FilterChip.spec.tsx +77 -0
- package/src/components/DataTableFilterChips/FilterChip.stories.tsx +111 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/table",
|
|
3
|
-
"version": "7.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,13 +80,13 @@
|
|
|
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/components": "7.7.
|
|
84
|
-
"@multiplatform.one/forms": "7.7.
|
|
85
|
-
"@multiplatform.one/platform": "7.7.
|
|
86
|
-
"@multiplatform.one/rich-text": "7.7.
|
|
87
|
-
"@multiplatform.one/router": "7.7.
|
|
88
|
-
"@multiplatform.one/table-primitives": "7.7.
|
|
89
|
-
"@multiplatform.one/theme": "7.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",
|
|
@@ -98,10 +98,10 @@
|
|
|
98
98
|
"react": "19.2.5",
|
|
99
99
|
"react-dom": "19.2.5",
|
|
100
100
|
"vitest": "^4.1.5",
|
|
101
|
-
"@multiplatform.one/config": "7.7.
|
|
102
|
-
"@multiplatform.one/router": "7.7.
|
|
103
|
-
"@multiplatform.one/storybook": "7.7.
|
|
104
|
-
"@multiplatform.one/test-utils": "7.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"
|
|
105
105
|
},
|
|
106
106
|
"peerDependencies": {
|
|
107
107
|
"i18next": "^25.0.0",
|
|
@@ -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
|
+
};
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataTableDocumentList specs — the list body's OWN contract.
|
|
3
|
+
*
|
|
4
|
+
* cardLayout.spec.tsx proves layout=list picks this body over the grid and the
|
|
5
|
+
* cards, that the title slot honours meta.role, and that rows are a nowrap flex
|
|
6
|
+
* list rather than a column grid. What no spec pinned is the SLOT TAXONOMY the
|
|
7
|
+
* list is built on (documentListSlots.ts): which column lands in the trailing
|
|
8
|
+
* indicator cluster, which becomes muted meta, which is dropped outright, and
|
|
9
|
+
* what happens when nothing declares a role at all. Plus the row head
|
|
10
|
+
* (selection) and the two empty-state copies.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
14
|
+
import { screen, waitFor } from "@testing-library/react";
|
|
15
|
+
import { describe, expect, it, vi } from "vitest";
|
|
16
|
+
import type { DataTableColumn, TableFilter } from "../../types";
|
|
17
|
+
import { DataTable } from "./index";
|
|
18
|
+
|
|
19
|
+
type Task = {
|
|
20
|
+
id: number;
|
|
21
|
+
title: string;
|
|
22
|
+
owner: string;
|
|
23
|
+
status: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const tasks: Task[] = [
|
|
27
|
+
{ id: 1, title: "Ship the card layout", owner: "Ada", status: "Open" },
|
|
28
|
+
{ id: 2, title: "Rewire the chips", owner: "Grace", status: "Blocked" },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const columns: DataTableColumn<Task>[] = [
|
|
32
|
+
{ accessorKey: "title", header: "Title", meta: { role: "title" } },
|
|
33
|
+
{ accessorKey: "owner", header: "Owner" },
|
|
34
|
+
{ accessorKey: "status", header: "Status", meta: { role: "indicator" } },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
function renderList(props: Record<string, any> = {}) {
|
|
38
|
+
return renderWithProviders(
|
|
39
|
+
<DataTable data={tasks} columns={columns} layout="list" onStateChange={vi.fn()} {...props} />,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const slots = (container: HTMLElement, slot: string) =>
|
|
44
|
+
Array.from(container.querySelectorAll(`[data-list-slot="${slot}"]`)).map((n) => n.textContent);
|
|
45
|
+
|
|
46
|
+
describe("DataTableDocumentList", () => {
|
|
47
|
+
it("renders one row per document inside the list container", () => {
|
|
48
|
+
const { container } = renderList();
|
|
49
|
+
expect(container.querySelector('[data-testid="data-table-list"]')).not.toBeNull();
|
|
50
|
+
expect(container.querySelectorAll('[data-testid="data-table-list-row"]').length).toBe(
|
|
51
|
+
tasks.length,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("splits a row into title, muted meta and a trailing indicator", () => {
|
|
56
|
+
const { container } = renderList();
|
|
57
|
+
expect(slots(container, "title")[0]).toBe("Ship the card layout");
|
|
58
|
+
expect(slots(container, "meta")).toContain("Ada");
|
|
59
|
+
expect(slots(container, "indicator")).toContain("Open");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("with no roles declared the first content column leads and status still indicates", () => {
|
|
63
|
+
const { container } = renderList({
|
|
64
|
+
columns: [
|
|
65
|
+
{ accessorKey: "title", header: "Title" },
|
|
66
|
+
{ accessorKey: "owner", header: "Owner" },
|
|
67
|
+
{ accessorKey: "status", header: "Status" },
|
|
68
|
+
],
|
|
69
|
+
});
|
|
70
|
+
expect(slots(container, "title")[0]).toBe("Ship the card layout");
|
|
71
|
+
expect(slots(container, "meta")).toContain("Ada");
|
|
72
|
+
// `status` reaches the indicator cluster on its id alone, no meta.role.
|
|
73
|
+
expect(slots(container, "indicator")).toContain("Open");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("an underscore-prefixed column is an action, never muted meta", () => {
|
|
77
|
+
const { container } = renderList({
|
|
78
|
+
columns: [
|
|
79
|
+
{ accessorKey: "title", header: "Title", meta: { role: "title" } },
|
|
80
|
+
{ id: "_liked_by", accessorKey: "owner", header: "♥", meta: { label: "Liked" } },
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
expect(slots(container, "actions")).toContain("Ada");
|
|
84
|
+
expect(slots(container, "meta")).not.toContain("Ada");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("a hidden-role column is dropped from every slot, not merely styled down", () => {
|
|
88
|
+
const { container } = renderList({
|
|
89
|
+
columns: [
|
|
90
|
+
{ accessorKey: "title", header: "Title", meta: { role: "title" } },
|
|
91
|
+
{ accessorKey: "owner", header: "Owner", meta: { role: "hidden" } },
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
expect(slots(container, "title")[0]).toBe("Ship the card layout");
|
|
95
|
+
expect(slots(container, "meta")).not.toContain("Ada");
|
|
96
|
+
expect(slots(container, "indicator")).not.toContain("Ada");
|
|
97
|
+
expect(slots(container, "actions")).not.toContain("Ada");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("a list is not a form: column headers never reprint as row labels", () => {
|
|
101
|
+
renderList();
|
|
102
|
+
expect(screen.queryByText("Owner")).toBeNull();
|
|
103
|
+
expect(screen.queryByText("Status")).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("selection puts a named checkbox at the head of every row", () => {
|
|
107
|
+
renderList({ enableRowSelection: true });
|
|
108
|
+
expect(screen.getAllByLabelText(/^Select row /).length).toBe(tasks.length);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("empty with no filters reads 'No data available'", () => {
|
|
112
|
+
renderWithProviders(
|
|
113
|
+
<DataTable data={[]} columns={columns} layout="list" onStateChange={vi.fn()} />,
|
|
114
|
+
);
|
|
115
|
+
expect(screen.getByText("No data available")).toBeTruthy();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("empty because a filter matched nothing reads 'No results found'", async () => {
|
|
119
|
+
const noMatch: TableFilter[] = [
|
|
120
|
+
{
|
|
121
|
+
id: "flt-owner",
|
|
122
|
+
columnId: "owner",
|
|
123
|
+
value: { operator: "contains", value: "zzzz" },
|
|
124
|
+
label: "Owner",
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
const { container } = renderWithProviders(
|
|
128
|
+
<DataTable
|
|
129
|
+
data={tasks}
|
|
130
|
+
columns={columns}
|
|
131
|
+
layout="list"
|
|
132
|
+
enableFiltering
|
|
133
|
+
filterableFields={[{ id: "owner", label: "Owner", type: "text" }]}
|
|
134
|
+
initialState={{ filters: noMatch }}
|
|
135
|
+
onStateChange={vi.fn()}
|
|
136
|
+
/>,
|
|
137
|
+
);
|
|
138
|
+
await waitFor(() => {
|
|
139
|
+
expect(container.querySelectorAll('[data-testid="data-table-list-row"]').length).toBe(0);
|
|
140
|
+
expect(screen.getByText("No results found")).toBeTruthy();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataTableMobileCards specs — the card body's OWN contract.
|
|
3
|
+
*
|
|
4
|
+
* cardLayout.spec.tsx proves the layout switch, the title slot, label/value
|
|
5
|
+
* rows, column visibility, sorting, search and filtering through the compound
|
|
6
|
+
* table. What no spec pinned is the card's header chrome and its empty state:
|
|
7
|
+
* the select-all bar above the stack, the per-card checkbox / row number /
|
|
8
|
+
* delete cluster beside the title, the delete floor at `minRows`, and the two
|
|
9
|
+
* distinct empty-state copies (no data vs no results) — plus the rule that an
|
|
10
|
+
* in-flight refetch paints NO empty state over a momentarily blank body.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
14
|
+
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|
15
|
+
import { describe, expect, it, vi } from "vitest";
|
|
16
|
+
import type { DataTableColumn, TableFilter } from "../../types";
|
|
17
|
+
import { DataTable } from "./index";
|
|
18
|
+
|
|
19
|
+
type Person = {
|
|
20
|
+
id: number;
|
|
21
|
+
firstName: string;
|
|
22
|
+
lastName: string;
|
|
23
|
+
age: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const people: Person[] = [
|
|
27
|
+
{ id: 1, firstName: "John", lastName: "Doe", age: 28 },
|
|
28
|
+
{ id: 2, firstName: "Jane", lastName: "Smith", age: 32 },
|
|
29
|
+
{ id: 3, firstName: "Bob", lastName: "Johnson", age: 45 },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const columns: DataTableColumn<Person>[] = [
|
|
33
|
+
{ accessorKey: "firstName", header: "First Name" },
|
|
34
|
+
{ accessorKey: "lastName", header: "Last Name" },
|
|
35
|
+
{ accessorKey: "age", header: "Age" },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function renderCards(props: Record<string, any> = {}) {
|
|
39
|
+
return renderWithProviders(
|
|
40
|
+
<DataTable data={people} columns={columns} layout="cards" onStateChange={vi.fn()} {...props} />,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function cards(container: HTMLElement): HTMLElement[] {
|
|
45
|
+
return Array.from(container.querySelectorAll('[data-testid="data-table-card"]'));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("DataTableMobileCards", () => {
|
|
49
|
+
it("renders one card per row inside the card list container", () => {
|
|
50
|
+
const { container } = renderCards();
|
|
51
|
+
expect(container.querySelector('[data-testid="data-table-cards"]')).not.toBeNull();
|
|
52
|
+
expect(cards(container).length).toBe(people.length);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("the first visible column is the card title, the rest are label/value rows", () => {
|
|
56
|
+
const { container } = renderCards();
|
|
57
|
+
const first = cards(container)[0];
|
|
58
|
+
expect(first.querySelector('[data-card-slot="title"]')?.textContent).toBe("John");
|
|
59
|
+
expect(first.textContent).toContain("Last Name");
|
|
60
|
+
expect(first.textContent).toContain("Doe");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("a single visible column gives a title and no field rows", () => {
|
|
64
|
+
const { container } = renderCards({
|
|
65
|
+
columns: [{ accessorKey: "firstName", header: "First Name" }],
|
|
66
|
+
});
|
|
67
|
+
const first = cards(container)[0];
|
|
68
|
+
expect(first.querySelector('[data-card-slot="title"]')?.textContent).toBe("John");
|
|
69
|
+
expect(first.textContent).not.toContain("Last Name");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("selection adds a select-all bar above the stack and a checkbox per card", () => {
|
|
73
|
+
const { container } = renderCards({ enableRowSelection: true });
|
|
74
|
+
expect(screen.getByLabelText("Select all rows")).toBeTruthy();
|
|
75
|
+
expect(screen.getAllByLabelText(/^Select row /).length).toBe(people.length);
|
|
76
|
+
expect(cards(container).length).toBe(people.length);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("without selection there is no select-all bar", () => {
|
|
80
|
+
renderCards();
|
|
81
|
+
expect(screen.queryByLabelText("Select all rows")).toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("select-all checks every card's checkbox", async () => {
|
|
85
|
+
renderCards({ enableRowSelection: true });
|
|
86
|
+
fireEvent.click(screen.getByLabelText("Select all rows"));
|
|
87
|
+
await waitFor(() => {
|
|
88
|
+
const boxes = screen.getAllByLabelText(/^Select row /);
|
|
89
|
+
for (const box of boxes) expect(box.getAttribute("aria-checked")).toBe("true");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("row numbers print in the card header, one-based", () => {
|
|
94
|
+
const { container } = renderCards({ showRowNumbers: true });
|
|
95
|
+
expect(cards(container)[0].textContent).toContain("#1");
|
|
96
|
+
expect(cards(container)[2].textContent).toContain("#3");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("delete is offered per card and reports the row removal to the owner", () => {
|
|
100
|
+
const onDataChange = vi.fn();
|
|
101
|
+
renderCards({ showDeleteRow: true, minRows: 1, onDataChange });
|
|
102
|
+
const deletes = screen.getAllByLabelText(/Delete row/i);
|
|
103
|
+
expect(deletes.length).toBe(people.length);
|
|
104
|
+
fireEvent.click(deletes[0]);
|
|
105
|
+
// The parent owns `data`; the card list asks, it does not splice.
|
|
106
|
+
expect(onDataChange).toHaveBeenCalledTimes(1);
|
|
107
|
+
expect(onDataChange.mock.calls[0][0]).toEqual(people.slice(1));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("delete goes inert at the minRows floor rather than emptying the list", () => {
|
|
111
|
+
const onDataChange = vi.fn();
|
|
112
|
+
renderCards({ showDeleteRow: true, minRows: people.length, onDataChange });
|
|
113
|
+
const deletes = screen.getAllByLabelText(/Delete row/i);
|
|
114
|
+
expect(deletes.length).toBe(people.length);
|
|
115
|
+
fireEvent.click(deletes[0]);
|
|
116
|
+
expect(onDataChange).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("empty with no filters reads 'No data available'", () => {
|
|
120
|
+
renderWithProviders(
|
|
121
|
+
<DataTable data={[]} columns={columns} layout="cards" onStateChange={vi.fn()} />,
|
|
122
|
+
);
|
|
123
|
+
expect(screen.getByText("No data available")).toBeTruthy();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("empty because a filter matched nothing reads 'No results found'", async () => {
|
|
127
|
+
const noMatch: TableFilter[] = [
|
|
128
|
+
{
|
|
129
|
+
id: "flt-first",
|
|
130
|
+
columnId: "firstName",
|
|
131
|
+
value: { operator: "contains", value: "zzzz" },
|
|
132
|
+
label: "First Name",
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
const { container } = renderWithProviders(
|
|
136
|
+
<DataTable
|
|
137
|
+
data={people}
|
|
138
|
+
columns={columns}
|
|
139
|
+
layout="cards"
|
|
140
|
+
enableFiltering
|
|
141
|
+
filterableFields={[{ id: "firstName", label: "First Name", type: "text" }]}
|
|
142
|
+
initialState={{ filters: noMatch }}
|
|
143
|
+
onStateChange={vi.fn()}
|
|
144
|
+
/>,
|
|
145
|
+
);
|
|
146
|
+
await waitFor(() => {
|
|
147
|
+
expect(cards(container).length).toBe(0);
|
|
148
|
+
expect(screen.getByText("No results found")).toBeTruthy();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CellOptionsEditor — the popover/sheet host for option groups too big for a
|
|
3
|
+
* cell (UX-022 / LC-06 OVERSIZED-EDIT).
|
|
4
|
+
*
|
|
5
|
+
* A radio group rendered inline in a narrow column has options the user cannot
|
|
6
|
+
* reach: the cell's overflow guard clips them. So the cell face becomes a
|
|
7
|
+
* compact FieldDisplay trigger showing the current option label, and the full
|
|
8
|
+
* list opens in a forms FloatingPanel — an anchored popover with collision
|
|
9
|
+
* handling on desktop web, a bottom sheet on native and small viewports —
|
|
10
|
+
* seeded at the cell's current value (Axiom 11).
|
|
11
|
+
*
|
|
12
|
+
* Two classes. COMMIT (`dismissOnPick`, the default) closes on a pick,
|
|
13
|
+
* including re-picking the value already selected. COMPOSE (multi-select
|
|
14
|
+
* toggles) stays open until Escape or an outside click, so several options can
|
|
15
|
+
* be composed in one visit.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Meta } from "@storybook/react-native-web-vite";
|
|
19
|
+
import { RadioGroup, ToggleGroup } from "@multiplatform.one/forms";
|
|
20
|
+
import { useState } from "react";
|
|
21
|
+
import { Paragraph, View, YStack } from "tamagui";
|
|
22
|
+
import { CellOptionsEditor } from "./CellOptionsEditor";
|
|
23
|
+
|
|
24
|
+
const meta: Meta<typeof CellOptionsEditor> = {
|
|
25
|
+
title: "Table/CellOptionsEditor",
|
|
26
|
+
component: CellOptionsEditor,
|
|
27
|
+
parameters: {
|
|
28
|
+
status: { type: "stable" },
|
|
29
|
+
docs: {
|
|
30
|
+
description: {
|
|
31
|
+
component:
|
|
32
|
+
"Compact cell face plus a FloatingPanel holding the full option list. " +
|
|
33
|
+
"COMMIT groups close on a pick; COMPOSE groups stay open.",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export default meta;
|
|
40
|
+
|
|
41
|
+
const priorities = [
|
|
42
|
+
{ label: "Urgent", value: "urgent" },
|
|
43
|
+
{ label: "High", value: "high" },
|
|
44
|
+
{ label: "Medium", value: "medium" },
|
|
45
|
+
{ label: "Low", value: "low" },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const labels = [
|
|
49
|
+
{ label: "Bug", value: "bug" },
|
|
50
|
+
{ label: "Design", value: "design" },
|
|
51
|
+
{ label: "Infra", value: "infra" },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/** A narrow frame, so the reason the panel exists is visible: the cell clips. */
|
|
55
|
+
function CellFrame({ children }: { children: React.ReactNode }) {
|
|
56
|
+
return (
|
|
57
|
+
<View width={180} borderWidth={1} borderColor="$color6" borderRadius="$2" overflow="hidden">
|
|
58
|
+
{children}
|
|
59
|
+
</View>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function CommitCell() {
|
|
64
|
+
const [value, setValue] = useState("high");
|
|
65
|
+
return (
|
|
66
|
+
<YStack padding="$4" gap="$3" alignItems="flex-start">
|
|
67
|
+
<Paragraph size="$2">COMMIT: picking is the exit. Panel closes on any pick.</Paragraph>
|
|
68
|
+
<CellFrame>
|
|
69
|
+
<CellOptionsEditor field="radiogroup" value={value} label="Priority">
|
|
70
|
+
{(close) => (
|
|
71
|
+
<RadioGroup
|
|
72
|
+
value={value}
|
|
73
|
+
onValueChange={(next: string) => {
|
|
74
|
+
setValue(next);
|
|
75
|
+
close();
|
|
76
|
+
}}
|
|
77
|
+
options={priorities}
|
|
78
|
+
orientation="vertical"
|
|
79
|
+
/>
|
|
80
|
+
)}
|
|
81
|
+
</CellOptionsEditor>
|
|
82
|
+
</CellFrame>
|
|
83
|
+
<Paragraph size="$2" testID="committed-value">{`value: ${value}`}</Paragraph>
|
|
84
|
+
</YStack>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const Default = {
|
|
89
|
+
name: "Main",
|
|
90
|
+
render: () => <CommitCell />,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function ComposeCell() {
|
|
94
|
+
const [value, setValue] = useState<string[]>(["bug"]);
|
|
95
|
+
return (
|
|
96
|
+
<YStack padding="$4" gap="$3" alignItems="flex-start">
|
|
97
|
+
<Paragraph size="$2">
|
|
98
|
+
COMPOSE: multi-select stays open so several options land in one visit.
|
|
99
|
+
</Paragraph>
|
|
100
|
+
<CellFrame>
|
|
101
|
+
<CellOptionsEditor field="togglegroup" value={value} label="Labels" dismissOnPick={false}>
|
|
102
|
+
{() => (
|
|
103
|
+
<ToggleGroup
|
|
104
|
+
value={value}
|
|
105
|
+
onValueChange={(next: string | string[]) =>
|
|
106
|
+
setValue(Array.isArray(next) ? next : [next])
|
|
107
|
+
}
|
|
108
|
+
options={labels}
|
|
109
|
+
type="multiple"
|
|
110
|
+
orientation="vertical"
|
|
111
|
+
/>
|
|
112
|
+
)}
|
|
113
|
+
</CellOptionsEditor>
|
|
114
|
+
</CellFrame>
|
|
115
|
+
<Paragraph size="$2" testID="committed-value">{`value: ${value.join(", ")}`}</Paragraph>
|
|
116
|
+
</YStack>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** COMPOSE class: `dismissOnPick={false}` keeps the panel open across picks. */
|
|
121
|
+
export const Compose = {
|
|
122
|
+
render: () => <ComposeCell />,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Disabled: the face renders alone at reduced opacity, with no button role and
|
|
127
|
+
* no panel — a disabled cell is not a keyboard stop.
|
|
128
|
+
*/
|
|
129
|
+
export const Disabled = {
|
|
130
|
+
render: () => (
|
|
131
|
+
<YStack padding="$4" gap="$3" alignItems="flex-start">
|
|
132
|
+
<CellFrame>
|
|
133
|
+
<CellOptionsEditor field="radiogroup" value="medium" label="Priority" disabled>
|
|
134
|
+
{() => <RadioGroup value="medium" options={priorities} orientation="vertical" />}
|
|
135
|
+
</CellOptionsEditor>
|
|
136
|
+
</CellFrame>
|
|
137
|
+
</YStack>
|
|
138
|
+
),
|
|
139
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CellOptionsEditor specs — the popover host's OWN contract (UX-022 / LC-06).
|
|
3
|
+
*
|
|
4
|
+
* dataTableCell.spec.tsx drives this host through DataTableCell with a faked
|
|
5
|
+
* FloatingPanel, so it proves the CELL wires it up. Nothing pinned the host
|
|
6
|
+
* itself: that a disabled editor collapses to a bare face with no trigger and
|
|
7
|
+
* no keyboard stop, that the face is chromeless on focus because the ring
|
|
8
|
+
* belongs to the parent cell (LC-71), that the panel body leaves table-cell
|
|
9
|
+
* context so option groups get their full chrome back, and that `close` is
|
|
10
|
+
* handed to the children so a COMMIT-class pick can dismiss.
|
|
11
|
+
*
|
|
12
|
+
* The panel is faked the same way the sibling cell spec fakes it: content only
|
|
13
|
+
* while open, trigger toggles.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React from "react";
|
|
17
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
18
|
+
import { fireEvent, screen } from "@testing-library/react";
|
|
19
|
+
import { describe, expect, it, vi } from "vitest";
|
|
20
|
+
|
|
21
|
+
vi.mock("@multiplatform.one/forms", async (importOriginal) => ({
|
|
22
|
+
...(await importOriginal<typeof import("@multiplatform.one/forms")>()),
|
|
23
|
+
FieldDisplay: (props: { field?: string; value?: unknown }) =>
|
|
24
|
+
React.createElement(
|
|
25
|
+
"div",
|
|
26
|
+
{ "data-testid": "mock-FieldDisplay", "data-field": props.field },
|
|
27
|
+
String(props.value ?? ""),
|
|
28
|
+
),
|
|
29
|
+
FloatingPanel: ({ open, onOpenChange, trigger, children }: any) =>
|
|
30
|
+
React.createElement(
|
|
31
|
+
"div",
|
|
32
|
+
{ "data-testid": "mock-FloatingPanel" },
|
|
33
|
+
React.createElement(
|
|
34
|
+
"div",
|
|
35
|
+
{ "data-testid": "floating-panel-trigger", onClick: () => onOpenChange?.(!open) },
|
|
36
|
+
trigger,
|
|
37
|
+
),
|
|
38
|
+
open
|
|
39
|
+
? React.createElement("div", { "data-testid": "floating-panel-content" }, children)
|
|
40
|
+
: null,
|
|
41
|
+
),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
vi.mock("@multiplatform.one/table-primitives", async (importOriginal) => {
|
|
45
|
+
const actual = await importOriginal<typeof import("@multiplatform.one/table-primitives")>();
|
|
46
|
+
return {
|
|
47
|
+
...actual,
|
|
48
|
+
TableCellContext: {
|
|
49
|
+
...actual.TableCellContext,
|
|
50
|
+
Provider: ({ value, children }: any) =>
|
|
51
|
+
React.createElement(
|
|
52
|
+
"div",
|
|
53
|
+
{ "data-testid": "cell-context", "data-in-table-cell": String(value?.inTableCell) },
|
|
54
|
+
children,
|
|
55
|
+
),
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
import { CellOptionsEditor } from "./CellOptionsEditor";
|
|
61
|
+
|
|
62
|
+
function renderEditor(props: Record<string, any> = {}, body?: (close: () => void) => any) {
|
|
63
|
+
return renderWithProviders(
|
|
64
|
+
<CellOptionsEditor field="radiogroup" value="high" label="Priority" {...props}>
|
|
65
|
+
{body ??
|
|
66
|
+
((close) => (
|
|
67
|
+
<button type="button" data-testid="pick" onClick={() => close()}>
|
|
68
|
+
pick
|
|
69
|
+
</button>
|
|
70
|
+
))}
|
|
71
|
+
</CellOptionsEditor>,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("CellOptionsEditor", () => {
|
|
76
|
+
it("renders the current value as a compact face, panel closed", () => {
|
|
77
|
+
renderEditor();
|
|
78
|
+
expect(screen.getByTestId("mock-FieldDisplay").textContent).toBe("high");
|
|
79
|
+
expect(screen.queryByTestId("floating-panel-content")).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("the face is a named, focusable button that reports its expanded state", () => {
|
|
83
|
+
const { container } = renderEditor();
|
|
84
|
+
const face = container.querySelector('[role="button"]') as HTMLElement;
|
|
85
|
+
expect(face).not.toBeNull();
|
|
86
|
+
expect(face.getAttribute("aria-label")).toBe("Priority");
|
|
87
|
+
expect(face.getAttribute("aria-expanded")).toBe("false");
|
|
88
|
+
expect(face.getAttribute("tabindex")).toBe("0");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("opening the panel flips aria-expanded and mounts the option group", () => {
|
|
92
|
+
const { container } = renderEditor();
|
|
93
|
+
fireEvent.click(screen.getByTestId("floating-panel-trigger"));
|
|
94
|
+
expect(screen.getByTestId("floating-panel-content")).toBeTruthy();
|
|
95
|
+
expect(
|
|
96
|
+
(container.querySelector('[role="button"]') as HTMLElement).getAttribute("aria-expanded"),
|
|
97
|
+
).toBe("true");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("hands `close` to the children so a COMMIT pick can dismiss the panel", () => {
|
|
101
|
+
renderEditor();
|
|
102
|
+
fireEvent.click(screen.getByTestId("floating-panel-trigger"));
|
|
103
|
+
fireEvent.click(screen.getByTestId("pick"));
|
|
104
|
+
expect(screen.queryByTestId("floating-panel-content")).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("the panel body leaves table-cell context so options get full chrome back", () => {
|
|
108
|
+
renderEditor();
|
|
109
|
+
fireEvent.click(screen.getByTestId("floating-panel-trigger"));
|
|
110
|
+
const ctx = screen.getByTestId("cell-context");
|
|
111
|
+
expect(ctx.getAttribute("data-in-table-cell")).toBe("false");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("disabled collapses to a bare face: no panel host, no button role, no tab stop", () => {
|
|
115
|
+
const { container } = renderEditor({ disabled: true });
|
|
116
|
+
expect(screen.getByTestId("mock-FieldDisplay").textContent).toBe("high");
|
|
117
|
+
expect(screen.queryByTestId("mock-FloatingPanel")).toBeNull();
|
|
118
|
+
expect(container.querySelector('[role="button"]')).toBeNull();
|
|
119
|
+
expect(container.querySelector("[tabindex]")).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("the face paints no ring of its own — the parent cell owns the one ring (LC-71)", () => {
|
|
123
|
+
const { container } = renderEditor();
|
|
124
|
+
const face = container.querySelector('[role="button"]') as HTMLElement;
|
|
125
|
+
expect(face.style.outlineWidth === "" || face.style.outlineWidth === "0px").toBe(true);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -55,12 +55,17 @@ const mockColumns = [
|
|
|
55
55
|
const mockCreateObjectURL = vi.fn(() => "mock-url");
|
|
56
56
|
const mockRevokeObjectURL = vi.fn();
|
|
57
57
|
|
|
58
|
+
// configurable: true is load-bearing — the "Blob URLs are unavailable" case
|
|
59
|
+
// below redefines createObjectURL, and defineProperty defaults configurable to
|
|
60
|
+
// false, which made that redefine throw "Cannot redefine property".
|
|
58
61
|
Object.defineProperty(window.URL, "createObjectURL", {
|
|
62
|
+
configurable: true,
|
|
59
63
|
writable: true,
|
|
60
64
|
value: mockCreateObjectURL,
|
|
61
65
|
});
|
|
62
66
|
|
|
63
67
|
Object.defineProperty(window.URL, "revokeObjectURL", {
|
|
68
|
+
configurable: true,
|
|
64
69
|
writable: true,
|
|
65
70
|
value: mockRevokeObjectURL,
|
|
66
71
|
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterChip unit specs — the pill on its own, no DataTable around it.
|
|
3
|
+
*
|
|
4
|
+
* DataTableFilterChips.spec.tsx drives the chip through the compound table and
|
|
5
|
+
* proves the bar's behaviour. This file pins the chip's OWN contract, which the
|
|
6
|
+
* bar cannot state: the `onPress`/no-`onPress` split (an editable chip is a
|
|
7
|
+
* button, a display-only chip is not), that dismiss does not also fire the edit
|
|
8
|
+
* press, and that Enter/Space activate both focusables.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
12
|
+
import { fireEvent, screen } from "@testing-library/react";
|
|
13
|
+
import { describe, expect, it, vi } from "vitest";
|
|
14
|
+
import { FilterChip } from "./FilterChip";
|
|
15
|
+
|
|
16
|
+
describe("FilterChip", () => {
|
|
17
|
+
it("renders the label and a dismiss button", () => {
|
|
18
|
+
renderWithProviders(<FilterChip label="Email: contains corp" onRemove={vi.fn()} />);
|
|
19
|
+
expect(screen.getByText("Email: contains corp")).toBeTruthy();
|
|
20
|
+
expect(screen.getByRole("button", { name: "Remove" })).toBeTruthy();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("with onPress the pill is an edit button; both focusables are tab stops (LC-71)", () => {
|
|
24
|
+
renderWithProviders(
|
|
25
|
+
<FilterChip label="Email: contains corp" onPress={vi.fn()} onRemove={vi.fn()} />,
|
|
26
|
+
);
|
|
27
|
+
const edit = screen.getByRole("button", { name: "Edit filter Email: contains corp" });
|
|
28
|
+
const remove = screen.getByRole("button", { name: "Remove" });
|
|
29
|
+
expect(edit.getAttribute("tabindex")).toBe("0");
|
|
30
|
+
expect(remove.getAttribute("tabindex")).toBe("0");
|
|
31
|
+
expect(remove.className).toMatch(/mp-chip-dismiss/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("without onPress the pill is display-only: no edit button, dismiss still works", () => {
|
|
35
|
+
const onRemove = vi.fn();
|
|
36
|
+
renderWithProviders(<FilterChip label="Department: is Engineering" onRemove={onRemove} />);
|
|
37
|
+
expect(screen.queryByRole("button", { name: /Edit filter/ })).toBeNull();
|
|
38
|
+
fireEvent.click(screen.getByRole("button", { name: "Remove" }));
|
|
39
|
+
expect(onRemove).toHaveBeenCalledTimes(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("pressing the pill edits and does not remove", () => {
|
|
43
|
+
const onPress = vi.fn();
|
|
44
|
+
const onRemove = vi.fn();
|
|
45
|
+
renderWithProviders(<FilterChip label="Age: gt 30" onPress={onPress} onRemove={onRemove} />);
|
|
46
|
+
fireEvent.click(screen.getByRole("button", { name: "Edit filter Age: gt 30" }));
|
|
47
|
+
expect(onPress).toHaveBeenCalledTimes(1);
|
|
48
|
+
expect(onRemove).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("pressing dismiss removes without also firing the chip's edit press", () => {
|
|
52
|
+
const onPress = vi.fn();
|
|
53
|
+
const onRemove = vi.fn();
|
|
54
|
+
renderWithProviders(<FilterChip label="Age: gt 30" onPress={onPress} onRemove={onRemove} />);
|
|
55
|
+
fireEvent.click(screen.getByRole("button", { name: "Remove" }));
|
|
56
|
+
expect(onRemove).toHaveBeenCalledTimes(1);
|
|
57
|
+
expect(onPress).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("Enter and Space activate the edit press", () => {
|
|
61
|
+
const onPress = vi.fn();
|
|
62
|
+
renderWithProviders(<FilterChip label="Age: gt 30" onPress={onPress} onRemove={vi.fn()} />);
|
|
63
|
+
const edit = screen.getByRole("button", { name: "Edit filter Age: gt 30" });
|
|
64
|
+
fireEvent.keyDown(edit, { key: "Enter" });
|
|
65
|
+
fireEvent.keyDown(edit, { key: " " });
|
|
66
|
+
expect(onPress).toHaveBeenCalledTimes(2);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("Enter on the dismiss glyph removes and does not bubble into the edit press", () => {
|
|
70
|
+
const onPress = vi.fn();
|
|
71
|
+
const onRemove = vi.fn();
|
|
72
|
+
renderWithProviders(<FilterChip label="Age: gt 30" onPress={onPress} onRemove={onRemove} />);
|
|
73
|
+
fireEvent.keyDown(screen.getByRole("button", { name: "Remove" }), { key: "Enter" });
|
|
74
|
+
expect(onRemove).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(onPress).not.toHaveBeenCalled();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FilterChip — one active filter as a pill, standalone.
|
|
3
|
+
*
|
|
4
|
+
* DataTableFilterChips stories show the bar; this file shows the unit the bar
|
|
5
|
+
* is made of, with no table around it. Two focusables, two rings (LC-71):
|
|
6
|
+
* pressing the pill reopens the editor and rings the pill, pressing the X
|
|
7
|
+
* removes the filter and rings the glyph, never the 44px hit box. A chip with
|
|
8
|
+
* no `onPress` is display-only — no button role, no pill ring, dismiss still
|
|
9
|
+
* works.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { action } from "@multiplatform.one/storybook";
|
|
13
|
+
import type { Meta } from "@storybook/react-native-web-vite";
|
|
14
|
+
import { useState } from "react";
|
|
15
|
+
import { Paragraph, XStack, YStack } from "tamagui";
|
|
16
|
+
import { FilterChip } from "./FilterChip";
|
|
17
|
+
|
|
18
|
+
const meta: Meta<typeof FilterChip> = {
|
|
19
|
+
title: "Table/FilterChip",
|
|
20
|
+
component: FilterChip,
|
|
21
|
+
parameters: {
|
|
22
|
+
status: { type: "stable" },
|
|
23
|
+
docs: {
|
|
24
|
+
description: {
|
|
25
|
+
component:
|
|
26
|
+
"One active filter as a removable pill. Gray fill, label, dismiss glyph. " +
|
|
27
|
+
"Editable chips take a button role and a focus ring; display-only chips take neither.",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export default meta;
|
|
34
|
+
|
|
35
|
+
/** Editable: the pill is a button (edit) and the X is a second button (remove). */
|
|
36
|
+
export const Default = {
|
|
37
|
+
name: "Main",
|
|
38
|
+
render: () => (
|
|
39
|
+
<YStack padding="$4" gap="$3" alignItems="flex-start">
|
|
40
|
+
<FilterChip
|
|
41
|
+
label="Email contains corp"
|
|
42
|
+
onPress={action("press")}
|
|
43
|
+
onRemove={action("remove")}
|
|
44
|
+
/>
|
|
45
|
+
</YStack>
|
|
46
|
+
),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Without `onPress` the chip is display-only: no button role on the pill. */
|
|
50
|
+
export const DisplayOnly = {
|
|
51
|
+
render: () => (
|
|
52
|
+
<YStack padding="$4" gap="$3" alignItems="flex-start">
|
|
53
|
+
<FilterChip label="Department is Engineering" onRemove={action("remove")} />
|
|
54
|
+
</YStack>
|
|
55
|
+
),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** A row of siblings, as the chips bar renders them. Long labels do not truncate. */
|
|
59
|
+
export const Row = {
|
|
60
|
+
render: () => (
|
|
61
|
+
<YStack padding="$4" gap="$3" alignItems="flex-start">
|
|
62
|
+
<XStack gap="$2" flexWrap="wrap">
|
|
63
|
+
<FilterChip
|
|
64
|
+
label="Email contains corp"
|
|
65
|
+
onPress={action("press")}
|
|
66
|
+
onRemove={action("remove")}
|
|
67
|
+
/>
|
|
68
|
+
<FilterChip
|
|
69
|
+
label="Department is Engineering"
|
|
70
|
+
onPress={action("press")}
|
|
71
|
+
onRemove={action("remove")}
|
|
72
|
+
/>
|
|
73
|
+
<FilterChip
|
|
74
|
+
label="Age is greater than 30"
|
|
75
|
+
onPress={action("press")}
|
|
76
|
+
onRemove={action("remove")}
|
|
77
|
+
/>
|
|
78
|
+
</XStack>
|
|
79
|
+
</YStack>
|
|
80
|
+
),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
function RemovableRow() {
|
|
84
|
+
const [labels, setLabels] = useState([
|
|
85
|
+
"Email contains corp",
|
|
86
|
+
"Department is Engineering",
|
|
87
|
+
"Age is greater than 30",
|
|
88
|
+
]);
|
|
89
|
+
return (
|
|
90
|
+
<YStack padding="$4" gap="$3" alignItems="flex-start">
|
|
91
|
+
<XStack gap="$2" flexWrap="wrap">
|
|
92
|
+
{labels.map((label) => (
|
|
93
|
+
<FilterChip
|
|
94
|
+
key={label}
|
|
95
|
+
label={label}
|
|
96
|
+
onPress={action("press")}
|
|
97
|
+
onRemove={() => setLabels((current) => current.filter((item) => item !== label))}
|
|
98
|
+
/>
|
|
99
|
+
))}
|
|
100
|
+
</XStack>
|
|
101
|
+
<Paragraph size="$2" testID="chip-count">
|
|
102
|
+
{`${labels.length} active`}
|
|
103
|
+
</Paragraph>
|
|
104
|
+
</YStack>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Dismiss actually removes: press an X and the count drops. */
|
|
109
|
+
export const Removable = {
|
|
110
|
+
render: () => <RemovableRow />,
|
|
111
|
+
};
|