@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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataTableFilterButton spec (MPO-113). The button owns the toolbar Filter
|
|
3
|
+
* surface: trigger, searchable field list, live-commit into the shared
|
|
4
|
+
* operator/value editor. Chip rendering belongs to DataTableFilterChips.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
8
|
+
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import type { DataTableColumn, FilterableField, TableFilter } from "../../types";
|
|
11
|
+
import { DataTable } from "../DataTable";
|
|
12
|
+
|
|
13
|
+
type Person = {
|
|
14
|
+
id: number;
|
|
15
|
+
firstName: string;
|
|
16
|
+
age: number;
|
|
17
|
+
email: string;
|
|
18
|
+
department: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const data: Person[] = [
|
|
22
|
+
{ id: 1, firstName: "Ada", age: 36, email: "ada@corp.com", department: "Engineering" },
|
|
23
|
+
{ id: 2, firstName: "Grace", age: 45, email: "grace@corp.com", department: "Engineering" },
|
|
24
|
+
{ id: 3, firstName: "Alan", age: 41, email: "alan@other.org", department: "Marketing" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const columns: DataTableColumn<Person>[] = [
|
|
28
|
+
{ accessorKey: "firstName", header: "First Name" },
|
|
29
|
+
{ accessorKey: "age", header: "Age" },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const filterableFields: FilterableField[] = [
|
|
33
|
+
{ id: "email", label: "Email", type: "text" },
|
|
34
|
+
{
|
|
35
|
+
id: "department",
|
|
36
|
+
label: "Department",
|
|
37
|
+
type: "select",
|
|
38
|
+
options: [
|
|
39
|
+
{ label: "Engineering", value: "Engineering" },
|
|
40
|
+
{ label: "Marketing", value: "Marketing" },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
function emailFilter(value: string): TableFilter {
|
|
46
|
+
return {
|
|
47
|
+
id: "flt-email",
|
|
48
|
+
columnId: "email",
|
|
49
|
+
value: { operator: "contains", value },
|
|
50
|
+
label: "Email",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderTable(initialFilters: TableFilter[] = []) {
|
|
55
|
+
return renderWithProviders(
|
|
56
|
+
<DataTable
|
|
57
|
+
data={data}
|
|
58
|
+
columns={columns as DataTableColumn<Record<string, any>>[]}
|
|
59
|
+
filterableFields={filterableFields}
|
|
60
|
+
enableFiltering
|
|
61
|
+
enableGlobalSearch
|
|
62
|
+
initialState={{ filters: initialFilters }}
|
|
63
|
+
aria-label="People table"
|
|
64
|
+
/>,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("DataTableFilterButton", () => {
|
|
69
|
+
it("renders the toolbar Filter affordance when filterableFields exist", async () => {
|
|
70
|
+
renderTable([]);
|
|
71
|
+
expect(await screen.findByRole("button", { name: "Filter" })).toBeTruthy();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("shows the active filter count on the trigger", async () => {
|
|
75
|
+
renderTable([emailFilter("corp")]);
|
|
76
|
+
expect(await screen.findByText("Filter (1)")).toBeTruthy();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("field list is a single-tab-stop listbox (LC-49 / LC-71)", async () => {
|
|
80
|
+
renderTable([]);
|
|
81
|
+
fireEvent.click(await screen.findByRole("button", { name: "Filter" }));
|
|
82
|
+
const listbox = await screen.findByRole("listbox", { name: "Filterable fields" });
|
|
83
|
+
expect(listbox.getAttribute("tabindex")).toBe("0");
|
|
84
|
+
const options = screen.getAllByRole("option");
|
|
85
|
+
expect(options.length).toBeGreaterThan(0);
|
|
86
|
+
for (const option of options) {
|
|
87
|
+
expect(option.getAttribute("tabindex")).toBe("-1");
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("opens a searchable field list with visible columns grouped before hidden fields", async () => {
|
|
92
|
+
renderTable([]);
|
|
93
|
+
|
|
94
|
+
fireEvent.click(await screen.findByRole("button", { name: "Filter" }));
|
|
95
|
+
|
|
96
|
+
expect(await screen.findByText("Columns")).toBeTruthy();
|
|
97
|
+
expect(screen.getByText("More fields")).toBeTruthy();
|
|
98
|
+
|
|
99
|
+
expect(screen.getByText("Email")).toBeTruthy();
|
|
100
|
+
expect(screen.getByText("Department")).toBeTruthy();
|
|
101
|
+
|
|
102
|
+
const search = screen.getByPlaceholderText("Search fields...");
|
|
103
|
+
fireEvent.change(search, { target: { value: "dep" } });
|
|
104
|
+
await waitFor(() => {
|
|
105
|
+
expect(screen.queryByText("Email")).toBeNull();
|
|
106
|
+
expect(screen.getByText("Department")).toBeTruthy();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("live-commits a hidden-field filter (no Apply button)", async () => {
|
|
111
|
+
renderTable([]);
|
|
112
|
+
|
|
113
|
+
fireEvent.click(await screen.findByRole("button", { name: "Filter" }));
|
|
114
|
+
fireEvent.click(await screen.findByText("Email"));
|
|
115
|
+
|
|
116
|
+
expect(await screen.findByText("Condition")).toBeTruthy();
|
|
117
|
+
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
|
118
|
+
|
|
119
|
+
const valueInput = await screen.findByPlaceholderText("Enter value...");
|
|
120
|
+
fireEvent.change(valueInput, { target: { value: "other" } });
|
|
121
|
+
|
|
122
|
+
await waitFor(() => {
|
|
123
|
+
expect(screen.getByText("Email: contains other")).toBeTruthy();
|
|
124
|
+
expect(screen.getByText("Alan")).toBeTruthy();
|
|
125
|
+
expect(screen.queryByText("Ada")).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataTableFilterButton — toolbar Filter affordance. It reads
|
|
3
|
+
* DataTableStateContext, so these stories mount it the way the toolbar does:
|
|
4
|
+
* inside DataTable with filterableFields. Stage 1 is the searchable field
|
|
5
|
+
* list (visible columns first, then More fields). Stage 2 is the shared
|
|
6
|
+
* operator/value editor (MPO-101). Commit is live (LC-21).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Meta } from "@storybook/react-native-web-vite";
|
|
10
|
+
import { YStack } from "tamagui";
|
|
11
|
+
import type { DataTableColumn, FilterableField, TableFilter } from "../../types";
|
|
12
|
+
import { DataTable } from "../DataTable";
|
|
13
|
+
|
|
14
|
+
const meta: Meta = {
|
|
15
|
+
title: "Table/DataTableFilterButton",
|
|
16
|
+
parameters: {
|
|
17
|
+
status: { type: "stable" },
|
|
18
|
+
docs: {
|
|
19
|
+
description: {
|
|
20
|
+
component:
|
|
21
|
+
"Toolbar Filter control for any field, including hidden ones. Opens a " +
|
|
22
|
+
"FloatingPanel with a searchable field list, then the shared operator/value editor.",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default meta;
|
|
29
|
+
|
|
30
|
+
interface Person {
|
|
31
|
+
id: number;
|
|
32
|
+
firstName: string;
|
|
33
|
+
age: number;
|
|
34
|
+
email: string;
|
|
35
|
+
department: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const people: Person[] = [
|
|
39
|
+
{ id: 1, firstName: "Ada", age: 36, email: "ada@corp.com", department: "Engineering" },
|
|
40
|
+
{ id: 2, firstName: "Grace", age: 45, email: "grace@corp.com", department: "Engineering" },
|
|
41
|
+
{ id: 3, firstName: "Alan", age: 41, email: "alan@other.org", department: "Marketing" },
|
|
42
|
+
{ id: 4, firstName: "Katherine", age: 29, email: "kate@corp.com", department: "Design" },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const columns: DataTableColumn<Person>[] = [
|
|
46
|
+
{ accessorKey: "firstName", header: "First Name" },
|
|
47
|
+
{ accessorKey: "age", header: "Age" },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const filterableFields: FilterableField[] = [
|
|
51
|
+
{ id: "firstName", label: "First Name", type: "text" },
|
|
52
|
+
{ id: "age", label: "Age", type: "number" },
|
|
53
|
+
{ id: "email", label: "Email", type: "text" },
|
|
54
|
+
{
|
|
55
|
+
id: "department",
|
|
56
|
+
label: "Department",
|
|
57
|
+
type: "select",
|
|
58
|
+
options: [
|
|
59
|
+
{ label: "Engineering", value: "Engineering" },
|
|
60
|
+
{ label: "Marketing", value: "Marketing" },
|
|
61
|
+
{ label: "Design", value: "Design" },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
function emailContains(value: string): TableFilter {
|
|
67
|
+
return {
|
|
68
|
+
id: "flt-email",
|
|
69
|
+
columnId: "email",
|
|
70
|
+
value: { operator: "contains", value },
|
|
71
|
+
label: "Email",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function FilterTable({ initialFilters = [] }: { initialFilters?: TableFilter[] }) {
|
|
76
|
+
return (
|
|
77
|
+
<YStack padding="$4" width="100%">
|
|
78
|
+
<DataTable
|
|
79
|
+
data={people}
|
|
80
|
+
columns={columns}
|
|
81
|
+
filterableFields={filterableFields}
|
|
82
|
+
enableFiltering
|
|
83
|
+
enableGlobalSearch
|
|
84
|
+
initialState={{ filters: initialFilters }}
|
|
85
|
+
aria-label="People with toolbar filter"
|
|
86
|
+
/>
|
|
87
|
+
</YStack>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const Default = {
|
|
92
|
+
name: "Main",
|
|
93
|
+
render: () => <FilterTable />,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Active filters turn the trigger accent and stamp the count (Filter (N)). */
|
|
97
|
+
export const ActiveCount = {
|
|
98
|
+
render: () => <FilterTable initialFilters={[emailContains("corp")]} />,
|
|
99
|
+
};
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hidden-field filtering surface: chips bar
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Hidden-field filtering surface: chips bar, integration-tested through
|
|
3
|
+
* the compound DataTable (context wiring, client-side pre-filter, live
|
|
4
|
+
* state). The toolbar Filter button lives in DataTableFilterButton.spec.tsx.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { renderWithProviders } from "@multiplatform.one/test-utils";
|
|
8
|
-
import { screen, waitFor } from "@testing-library/react";
|
|
9
|
-
import userEvent from "@testing-library/user-event";
|
|
8
|
+
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|
10
9
|
import { describe, expect, it } from "vitest";
|
|
11
10
|
import type { DataTableColumn, FilterableField, TableFilter } from "../../types";
|
|
12
11
|
import { DataTable } from "../DataTable";
|
|
@@ -91,14 +90,13 @@ describe("DataTableFilterChips", () => {
|
|
|
91
90
|
});
|
|
92
91
|
|
|
93
92
|
it("removing the chip restores the rows", async () => {
|
|
94
|
-
const user = userEvent.setup();
|
|
95
93
|
renderTable([emailFilter("corp")]);
|
|
96
94
|
|
|
97
95
|
expect(await screen.findByText("Email: contains corp")).toBeTruthy();
|
|
98
96
|
expect(screen.queryByText("Alan")).toBeNull();
|
|
99
97
|
|
|
100
98
|
const removeButtons = screen.getAllByRole("button", { name: "Remove" });
|
|
101
|
-
|
|
99
|
+
fireEvent.click(removeButtons[0]);
|
|
102
100
|
|
|
103
101
|
await waitFor(() => {
|
|
104
102
|
expect(screen.queryByText("Email: contains corp")).toBeNull();
|
|
@@ -107,7 +105,6 @@ describe("DataTableFilterChips", () => {
|
|
|
107
105
|
});
|
|
108
106
|
|
|
109
107
|
it("shows Clear all at 2+ chips and clears every filter at once", async () => {
|
|
110
|
-
const user = userEvent.setup();
|
|
111
108
|
renderTable([emailFilter("corp"), departmentFilter("Engineering")]);
|
|
112
109
|
|
|
113
110
|
expect(await screen.findByText("Email: contains corp")).toBeTruthy();
|
|
@@ -115,7 +112,7 @@ describe("DataTableFilterChips", () => {
|
|
|
115
112
|
expect(screen.getByText("Department: is Engineering")).toBeTruthy();
|
|
116
113
|
|
|
117
114
|
const clearAll = screen.getByRole("button", { name: "Clear all" });
|
|
118
|
-
|
|
115
|
+
fireEvent.click(clearAll);
|
|
119
116
|
|
|
120
117
|
await waitFor(() => {
|
|
121
118
|
expect(screen.queryByText("Email: contains corp")).toBeNull();
|
|
@@ -138,75 +135,3 @@ describe("DataTableFilterChips", () => {
|
|
|
138
135
|
expect(remove.className).toMatch(/mp-chip-dismiss/);
|
|
139
136
|
});
|
|
140
137
|
});
|
|
141
|
-
|
|
142
|
-
describe("DataTableFilterButton", () => {
|
|
143
|
-
it("renders the toolbar Filter affordance when filterableFields exist", async () => {
|
|
144
|
-
renderTable([]);
|
|
145
|
-
expect(await screen.findByRole("button", { name: "Filter" })).toBeTruthy();
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it("shows the active filter count on the trigger", async () => {
|
|
149
|
-
renderTable([emailFilter("corp")]);
|
|
150
|
-
expect(await screen.findByText("Filter (1)")).toBeTruthy();
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("field list is a single-tab-stop listbox (LC-49 / LC-71)", async () => {
|
|
154
|
-
const user = userEvent.setup();
|
|
155
|
-
renderTable([]);
|
|
156
|
-
await user.click(await screen.findByRole("button", { name: "Filter" }));
|
|
157
|
-
const listbox = await screen.findByRole("listbox", { name: "Filterable fields" });
|
|
158
|
-
expect(listbox.getAttribute("tabindex")).toBe("0");
|
|
159
|
-
const options = screen.getAllByRole("option");
|
|
160
|
-
expect(options.length).toBeGreaterThan(0);
|
|
161
|
-
for (const option of options) {
|
|
162
|
-
expect(option.getAttribute("tabindex")).toBe("-1");
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it("opens a searchable field list with visible columns grouped before hidden fields", async () => {
|
|
167
|
-
const user = userEvent.setup();
|
|
168
|
-
renderTable([]);
|
|
169
|
-
|
|
170
|
-
const trigger = await screen.findByRole("button", { name: "Filter" });
|
|
171
|
-
await user.click(trigger);
|
|
172
|
-
|
|
173
|
-
// Group headers
|
|
174
|
-
expect(await screen.findByText("Columns")).toBeTruthy();
|
|
175
|
-
expect(screen.getByText("More fields")).toBeTruthy();
|
|
176
|
-
|
|
177
|
-
// Hidden fields listed
|
|
178
|
-
expect(screen.getByText("Email")).toBeTruthy();
|
|
179
|
-
expect(screen.getByText("Department")).toBeTruthy();
|
|
180
|
-
|
|
181
|
-
// Search narrows the list
|
|
182
|
-
const search = screen.getByPlaceholderText("Search fields...");
|
|
183
|
-
await user.type(search, "dep");
|
|
184
|
-
await waitFor(() => {
|
|
185
|
-
expect(screen.queryByText("Email")).toBeNull();
|
|
186
|
-
expect(screen.getByText("Department")).toBeTruthy();
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("live-commits a hidden-field filter (no Apply button)", async () => {
|
|
191
|
-
const user = userEvent.setup();
|
|
192
|
-
renderTable([]);
|
|
193
|
-
|
|
194
|
-
await user.click(await screen.findByRole("button", { name: "Filter" }));
|
|
195
|
-
await user.click(await screen.findByText("Email"));
|
|
196
|
-
|
|
197
|
-
// Operator/value editor opens with the shared editor (Condition select)
|
|
198
|
-
expect(await screen.findByText("Condition")).toBeTruthy();
|
|
199
|
-
// No Apply button in the live-commit surface (LC-21)
|
|
200
|
-
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
|
201
|
-
|
|
202
|
-
const valueInput = await screen.findByPlaceholderText("Enter value...");
|
|
203
|
-
await user.type(valueInput, "other");
|
|
204
|
-
|
|
205
|
-
// Filter applied live: chip + filtered rows without any Apply press
|
|
206
|
-
await waitFor(() => {
|
|
207
|
-
expect(screen.getByText("Email: contains other")).toBeTruthy();
|
|
208
|
-
expect(screen.getByText("Alan")).toBeTruthy();
|
|
209
|
-
expect(screen.queryByText("Ada")).toBeNull();
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
|
-
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DataTableFilterChips — every active filter as a removable pill under the
|
|
3
|
+
* toolbar (Axiom 6 HONEST STATE), including filters on fields with no
|
|
4
|
+
* visible column. Chip press reopens the shared editor; X removes; Clear
|
|
5
|
+
* all appears at 2+ chips.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Meta } from "@storybook/react-native-web-vite";
|
|
9
|
+
import { YStack } from "tamagui";
|
|
10
|
+
import type { DataTableColumn, FilterableField, TableFilter } from "../../types";
|
|
11
|
+
import { DataTable } from "../DataTable";
|
|
12
|
+
|
|
13
|
+
const meta: Meta = {
|
|
14
|
+
title: "Table/DataTableFilterChips",
|
|
15
|
+
parameters: {
|
|
16
|
+
status: { type: "stable" },
|
|
17
|
+
docs: {
|
|
18
|
+
description: {
|
|
19
|
+
component:
|
|
20
|
+
"Wrapping pills for every active filter. Gray fill, two focusables per chip " +
|
|
21
|
+
"(edit + dismiss), Clear all from the second chip on.",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default meta;
|
|
28
|
+
|
|
29
|
+
interface Person {
|
|
30
|
+
id: number;
|
|
31
|
+
firstName: string;
|
|
32
|
+
age: number;
|
|
33
|
+
email: string;
|
|
34
|
+
department: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const people: Person[] = [
|
|
38
|
+
{ id: 1, firstName: "Ada", age: 36, email: "ada@corp.com", department: "Engineering" },
|
|
39
|
+
{ id: 2, firstName: "Grace", age: 45, email: "grace@corp.com", department: "Engineering" },
|
|
40
|
+
{ id: 3, firstName: "Alan", age: 41, email: "alan@other.org", department: "Marketing" },
|
|
41
|
+
{ id: 4, firstName: "Katherine", age: 29, email: "kate@corp.com", department: "Design" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const columns: DataTableColumn<Person>[] = [
|
|
45
|
+
{ accessorKey: "firstName", header: "First Name" },
|
|
46
|
+
{ accessorKey: "age", header: "Age" },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const filterableFields: FilterableField[] = [
|
|
50
|
+
{ id: "email", label: "Email", type: "text" },
|
|
51
|
+
{
|
|
52
|
+
id: "department",
|
|
53
|
+
label: "Department",
|
|
54
|
+
type: "select",
|
|
55
|
+
options: [
|
|
56
|
+
{ label: "Engineering", value: "Engineering" },
|
|
57
|
+
{ label: "Marketing", value: "Marketing" },
|
|
58
|
+
{ label: "Design", value: "Design" },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
function emailContains(value: string): TableFilter {
|
|
64
|
+
return {
|
|
65
|
+
id: "flt-email",
|
|
66
|
+
columnId: "email",
|
|
67
|
+
value: { operator: "contains", value },
|
|
68
|
+
label: "Email",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function departmentIs(value: string): TableFilter {
|
|
73
|
+
return {
|
|
74
|
+
id: "flt-department",
|
|
75
|
+
columnId: "department",
|
|
76
|
+
value: { operator: "is", value },
|
|
77
|
+
label: "Department",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function ChipTable({ initialFilters }: { initialFilters: TableFilter[] }) {
|
|
82
|
+
return (
|
|
83
|
+
<YStack padding="$4" width="100%">
|
|
84
|
+
<DataTable
|
|
85
|
+
data={people}
|
|
86
|
+
columns={columns}
|
|
87
|
+
filterableFields={filterableFields}
|
|
88
|
+
enableFiltering
|
|
89
|
+
enableGlobalSearch
|
|
90
|
+
initialState={{ filters: initialFilters }}
|
|
91
|
+
aria-label="People with filter chips"
|
|
92
|
+
/>
|
|
93
|
+
</YStack>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const Default = {
|
|
98
|
+
name: "Main",
|
|
99
|
+
render: () => <ChipTable initialFilters={[emailContains("corp")]} />,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Clear all lands once two chips are active. */
|
|
103
|
+
export const ClearAll = {
|
|
104
|
+
render: () => <ChipTable initialFilters={[emailContains("corp"), departmentIs("Engineering")]} />,
|
|
105
|
+
};
|
|
@@ -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
|
+
};
|