@multiplatform.one/table 7.6.3 → 7.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/package.json +12 -13
  2. package/src/components/ChildTable/ChildTable.stories.tsx +4 -1
  3. package/src/components/DataTable/DataTable.spec.tsx +13 -7
  4. package/src/components/DataTable/DataTableCardControls.stories.tsx +116 -0
  5. package/src/components/DataTable/DataTableDocumentList.stories.tsx +132 -0
  6. package/src/components/DataTable/DataTableMobileCards.stories.tsx +124 -0
  7. package/src/components/DataTable/DataTableVirtualized.stories.tsx +69 -0
  8. package/src/components/DataTable/chromeInterplay.spec.tsx +4 -1
  9. package/src/components/DataTable/dataTableCardControls.spec.tsx +99 -0
  10. package/src/components/DataTable/dataTableDocumentList.spec.tsx +143 -0
  11. package/src/components/DataTable/dataTableMobileCards.spec.tsx +151 -0
  12. package/src/components/DataTableCell/CellOptionsEditor.stories.tsx +139 -0
  13. package/src/components/DataTableCell/cellOptionsEditor.spec.tsx +127 -0
  14. package/src/components/DataTableExport/DataTableExport.spec.tsx +5 -0
  15. package/src/components/DataTableFilterButton/DataTableFilterButton.spec.tsx +128 -0
  16. package/src/components/DataTableFilterButton/DataTableFilterButton.stories.tsx +99 -0
  17. package/src/components/DataTableFilterChips/DataTableFilterChips.spec.tsx +6 -81
  18. package/src/components/DataTableFilterChips/DataTableFilterChips.stories.tsx +105 -0
  19. package/src/components/DataTableFilterChips/FilterChip.spec.tsx +77 -0
  20. package/src/components/DataTableFilterChips/FilterChip.stories.tsx +111 -0
  21. package/src/components/FilterManager.stories.tsx +75 -0
  22. package/src/components/FilterOperatorValueEditor/FilterOperatorValueEditor.spec.tsx +261 -0
  23. package/src/components/FilterOperatorValueEditor/FilterOperatorValueEditor.stories.tsx +183 -0
  24. package/src/components/KnobDialog.spec.tsx +88 -0
  25. package/src/components/KnobDialog.stories.tsx +66 -0
  26. package/src/components/SavedFilterDropdown.stories.tsx +90 -0
@@ -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 + toolbar filter button,
3
- * integration-tested through the compound DataTable (context wiring,
4
- * client-side pre-filter, live state).
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
- await user.click(removeButtons[0]);
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
- await user.click(clearAll);
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
+ };