@multiplatform.one/table 7.6.2 → 7.7.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/table",
3
- "version": "7.6.2",
3
+ "version": "7.7.0",
4
4
  "description": "Advanced table components with filtering, pagination, sorting, and URL state management",
5
5
  "keywords": [
6
6
  "cross-platform",
@@ -80,18 +80,17 @@
80
80
  "@tanstack/react-table": "^8.21.3",
81
81
  "@tanstack/table-core": "^8.21.3",
82
82
  "tamagui": "2.7.6",
83
- "@multiplatform.one/components": "7.6.2",
84
- "@multiplatform.one/platform": "7.6.2",
85
- "@multiplatform.one/forms": "7.6.2",
86
- "@multiplatform.one/rich-text": "7.6.2",
87
- "@multiplatform.one/router": "7.6.2",
88
- "@multiplatform.one/table-primitives": "7.6.2",
89
- "@multiplatform.one/theme": "7.6.2"
83
+ "@multiplatform.one/components": "7.7.0",
84
+ "@multiplatform.one/forms": "7.7.0",
85
+ "@multiplatform.one/platform": "7.7.0",
86
+ "@multiplatform.one/rich-text": "7.7.0",
87
+ "@multiplatform.one/router": "7.7.0",
88
+ "@multiplatform.one/table-primitives": "7.7.0",
89
+ "@multiplatform.one/theme": "7.7.0"
90
90
  },
91
91
  "devDependencies": {
92
92
  "@tamagui/build": "2.7.6",
93
93
  "@testing-library/react": "^16.3.2",
94
- "@types/react": "~19.2.14",
95
94
  "@types/react-dom": "^19.2.3",
96
95
  "@vitejs/plugin-react": "^6.0.1",
97
96
  "@vitest/coverage-v8": "^4.1.5",
@@ -99,10 +98,10 @@
99
98
  "react": "19.2.5",
100
99
  "react-dom": "19.2.5",
101
100
  "vitest": "^4.1.5",
102
- "@multiplatform.one/config": "7.6.2",
103
- "@multiplatform.one/router": "7.6.2",
104
- "@multiplatform.one/storybook": "7.6.2",
105
- "@multiplatform.one/test-utils": "7.6.2"
101
+ "@multiplatform.one/config": "7.7.0",
102
+ "@multiplatform.one/router": "7.7.0",
103
+ "@multiplatform.one/storybook": "7.7.0",
104
+ "@multiplatform.one/test-utils": "7.7.0"
106
105
  },
107
106
  "peerDependencies": {
108
107
  "i18next": "^25.0.0",
@@ -30,7 +30,10 @@ const columns: DataTableColumn<LineItem>[] = [
30
30
  { accessorKey: "qty", header: "Qty", field: "stepper", fieldProps: { min: 0 } },
31
31
  ];
32
32
 
33
- const meta: Meta<typeof ChildTable> = {
33
+ // Instantiation expression: `Meta<typeof ChildTable>` resolves ChildTable's
34
+ // TRow to its default (DataTableData), so `args.columns` typed as
35
+ // DataTableColumn<DataTableData>[] and rejected the LineItem columns below.
36
+ const meta: Meta<typeof ChildTable<LineItem>> = {
34
37
  title: "Table/ChildTable",
35
38
  component: ChildTable,
36
39
  parameters: {
@@ -0,0 +1,69 @@
1
+ /**
2
+ * DataTableVirtualized is an internal body renderer. The public surface is
3
+ * DataTable with enableVirtualization — mounting the helper bare crashed
4
+ * playground SSR on virtualItems.map. This catalog row is that mode, not
5
+ * the helper's prop dump.
6
+ */
7
+
8
+ import type { Meta } from "@storybook/react-native-web-vite";
9
+ import { YStack } from "tamagui";
10
+ import type { DataTableColumn } from "../../types";
11
+ import { DataTable } from "./index";
12
+
13
+ const meta: Meta = {
14
+ title: "Table/DataTableVirtualized",
15
+ parameters: {
16
+ status: { type: "stable" },
17
+ docs: {
18
+ description: {
19
+ component:
20
+ "Virtual-scroll body for large row sets. Enable it on DataTable; do not mount " +
21
+ "DataTableVirtualized as a standalone component.",
22
+ },
23
+ },
24
+ },
25
+ };
26
+
27
+ export default meta;
28
+
29
+ interface Employee {
30
+ id: number;
31
+ name: string;
32
+ email: string;
33
+ department: string;
34
+ salary: number;
35
+ }
36
+
37
+ const departments = ["Engineering", "Marketing", "Sales", "Design", "Support"];
38
+
39
+ const rows: Employee[] = Array.from({ length: 200 }, (_, i) => ({
40
+ id: i + 1,
41
+ name: `Employee ${i + 1}`,
42
+ email: `emp${i + 1}@example.com`,
43
+ department: departments[i % departments.length],
44
+ salary: 50000 + (i % 80) * 1000,
45
+ }));
46
+
47
+ const columns: DataTableColumn<Employee>[] = [
48
+ { accessorKey: "id", header: "ID" },
49
+ { accessorKey: "name", header: "Name" },
50
+ { accessorKey: "email", header: "Email" },
51
+ { accessorKey: "department", header: "Department" },
52
+ { accessorKey: "salary", header: "Salary" },
53
+ ];
54
+
55
+ export const Default = {
56
+ name: "Main",
57
+ render: () => (
58
+ <YStack padding="$4" height={360}>
59
+ <DataTable
60
+ data={rows}
61
+ columns={columns}
62
+ enableVirtualization
63
+ virtualizationHeight={320}
64
+ enableGlobalSearch
65
+ aria-label="Virtualized employees"
66
+ />
67
+ </YStack>
68
+ ),
69
+ };
@@ -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,75 @@
1
+ /**
2
+ * FilterManager — save-current-filters dialog. Frappe-free: the caller
3
+ * supplies the filter tuples and the save callback. Playground already
4
+ * mounts this; the Kitchen Sink is story-driven, so this file is the
5
+ * on-device page.
6
+ */
7
+
8
+ import { action } from "@multiplatform.one/storybook";
9
+ import type { Meta } from "@storybook/react-native-web-vite";
10
+ import { YStack } from "tamagui";
11
+ import { FilterManager } from "./FilterManager";
12
+
13
+ const meta: Meta<typeof FilterManager> = {
14
+ title: "Table/FilterManager",
15
+ component: FilterManager,
16
+ parameters: {
17
+ status: { type: "stable" },
18
+ docs: {
19
+ description: {
20
+ component:
21
+ "Save-filter dialog: name, optional global checkbox, live count of the " +
22
+ "current filter tuples. Disabled with a reason when nothing is applied.",
23
+ },
24
+ },
25
+ },
26
+ };
27
+
28
+ export default meta;
29
+
30
+ const currentFilters: Array<[string, string, unknown]> = [
31
+ ["department", "=", "Engineering"],
32
+ ["salary", ">", 100000],
33
+ ];
34
+
35
+ export const Default = {
36
+ name: "Main",
37
+ render: () => (
38
+ <YStack padding="$4" alignItems="flex-start">
39
+ <FilterManager
40
+ currentFilters={currentFilters}
41
+ onSaveFilter={(name, isGlobal) => {
42
+ action("onSaveFilter")(name, isGlobal);
43
+ }}
44
+ />
45
+ </YStack>
46
+ ),
47
+ };
48
+
49
+ /** No filters applied: the save trigger stays focusable and explains why. */
50
+ export const DisabledEmpty = {
51
+ render: () => (
52
+ <YStack padding="$4" alignItems="flex-start">
53
+ <FilterManager
54
+ currentFilters={[]}
55
+ onSaveFilter={(name, isGlobal) => {
56
+ action("onSaveFilter")(name, isGlobal);
57
+ }}
58
+ />
59
+ </YStack>
60
+ ),
61
+ };
62
+
63
+ export const CanCreateGlobal = {
64
+ render: () => (
65
+ <YStack padding="$4" alignItems="flex-start">
66
+ <FilterManager
67
+ currentFilters={currentFilters}
68
+ canCreateGlobal
69
+ onSaveFilter={(name, isGlobal) => {
70
+ action("onSaveFilter")(name, isGlobal);
71
+ }}
72
+ />
73
+ </YStack>
74
+ ),
75
+ };
@@ -0,0 +1,261 @@
1
+ /**
2
+ * FilterOperatorValueEditor spec (MPO-101). The editor owns two things and
3
+ * nothing else: the operator vocabulary for a field type, and the value
4
+ * editor that type gets. Commit semantics belong to the surfaces
5
+ * (ColumnHeaderFilter / DataTableFilterButton) and are specced there.
6
+ */
7
+
8
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
9
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
10
+ import React, { useState } from "react";
11
+ import { describe, expect, it, vi } from "vitest";
12
+ import type { DataTableColumnType, FilterOperator } from "../../types";
13
+ import { filterOperatorLabel, operatorsForFilterType } from "../../utils/filterFields";
14
+ import { FilterOperatorValueEditor, type FilterOperatorValueEditorProps } from "./index";
15
+
16
+ const statusOptions = [
17
+ { label: "Open", value: "open" },
18
+ { label: "In progress", value: "in-progress" },
19
+ { label: "Done", value: "done" },
20
+ ];
21
+
22
+ const fieldTypes: DataTableColumnType[] = ["text", "number", "date", "boolean", "select"];
23
+
24
+ /** Every forms Select trigger in the tree, in render order (Condition first). */
25
+ const selectTriggers = (container: HTMLElement) =>
26
+ Array.from(container.querySelectorAll('[data-testid="select-trigger"]')) as HTMLElement[];
27
+
28
+ const textInputs = (container: HTMLElement) =>
29
+ Array.from(container.querySelectorAll("input")) as HTMLInputElement[];
30
+
31
+ async function openSelect(container: HTMLElement, trigger: HTMLElement) {
32
+ fireEvent.pointerDown(trigger);
33
+ fireEvent.mouseDown(trigger);
34
+ fireEvent.click(trigger);
35
+ await waitFor(() => {
36
+ expect(container.querySelectorAll('[role="option"]').length).toBeGreaterThan(0);
37
+ });
38
+ return Array.from(container.querySelectorAll('[role="option"]')) as HTMLElement[];
39
+ }
40
+
41
+ /**
42
+ * Choose an open Select option the way the keyboard does. Tamagui's SelectItem
43
+ * arms its click handler on a timer after the list opens (allowSelectRef), so a
44
+ * synthetic click right after opening is dropped; Enter selects unconditionally.
45
+ */
46
+ function pickOption(option: HTMLElement) {
47
+ fireEvent.keyDown(option, { key: "Enter" });
48
+ }
49
+
50
+ const optionLabels = (options: HTMLElement[]) =>
51
+ options.map((option) => option.textContent?.trim() ?? "");
52
+
53
+ function renderEditor(
54
+ props: Partial<FilterOperatorValueEditorProps> & { fieldType: DataTableColumnType },
55
+ ) {
56
+ const operator = props.operator ?? operatorsForFilterType(props.fieldType)[0];
57
+ return renderWithProviders(
58
+ <FilterOperatorValueEditor
59
+ value=""
60
+ onOperatorChange={vi.fn()}
61
+ onValueChange={vi.fn()}
62
+ {...props}
63
+ operator={operator}
64
+ />,
65
+ );
66
+ }
67
+
68
+ /** Controlled harness: round-trips operator + value like the surfaces do. */
69
+ function ControlledEditor({
70
+ fieldType,
71
+ onOperatorSpy,
72
+ onValueSpy,
73
+ options,
74
+ }: {
75
+ fieldType: DataTableColumnType;
76
+ onOperatorSpy: (operator: FilterOperator) => void;
77
+ onValueSpy: (value: string) => void;
78
+ options?: FilterOperatorValueEditorProps["options"];
79
+ }) {
80
+ const [operator, setOperator] = useState<FilterOperator>(operatorsForFilterType(fieldType)[0]);
81
+ const [value, setValue] = useState("");
82
+ return (
83
+ <FilterOperatorValueEditor
84
+ fieldType={fieldType}
85
+ operator={operator}
86
+ value={value}
87
+ options={options}
88
+ onOperatorChange={(next) => {
89
+ setOperator(next);
90
+ onOperatorSpy(next);
91
+ }}
92
+ onValueChange={(next) => {
93
+ setValue(next);
94
+ onValueSpy(next);
95
+ }}
96
+ />
97
+ );
98
+ }
99
+
100
+ describe("FilterOperatorValueEditor", () => {
101
+ describe("operator vocabulary", () => {
102
+ it.each(fieldTypes)(
103
+ "%s: the Condition select offers exactly operatorsForFilterType, labelled via filterOperatorLabel",
104
+ async (fieldType) => {
105
+ const { container } = renderEditor({ fieldType, options: statusOptions });
106
+ expect(screen.getByText("Condition")).toBeInTheDocument();
107
+
108
+ const [condition] = selectTriggers(container);
109
+ const options = await openSelect(container, condition);
110
+ expect(optionLabels(options)).toEqual(
111
+ operatorsForFilterType(fieldType).map((op) => filterOperatorLabel(op)),
112
+ );
113
+ },
114
+ );
115
+
116
+ it("picking a condition reports the operator, not its label", async () => {
117
+ const onOperator = vi.fn();
118
+ const { container } = renderWithProviders(
119
+ <ControlledEditor fieldType="text" onOperatorSpy={onOperator} onValueSpy={vi.fn()} />,
120
+ );
121
+ const [condition] = selectTriggers(container);
122
+ const options = await openSelect(container, condition);
123
+ const startsWith = options.find((option) => option.textContent?.trim() === "Starts with");
124
+ expect(startsWith).toBeDefined();
125
+ pickOption(startsWith!);
126
+ await waitFor(() => expect(onOperator).toHaveBeenLastCalledWith("startsWith"));
127
+ });
128
+ });
129
+
130
+ describe("value editor per field type", () => {
131
+ it("text: a plain Input with the generic placeholder", () => {
132
+ const { container } = renderEditor({ fieldType: "text" });
133
+ expect(screen.getByText("Value")).toBeInTheDocument();
134
+ const input = screen.getByPlaceholderText("Enter value...") as HTMLInputElement;
135
+ expect(input.getAttribute("inputmode")).not.toBe("numeric");
136
+ expect(selectTriggers(container)).toHaveLength(1);
137
+ });
138
+
139
+ it("number: the Input asks for a numeric keyboard", () => {
140
+ renderEditor({ fieldType: "number" });
141
+ const input = screen.getByPlaceholderText("Enter value...") as HTMLInputElement;
142
+ expect(input.getAttribute("inputmode")).toBe("numeric");
143
+ });
144
+
145
+ it("date: the Input hints the ISO day format", () => {
146
+ renderEditor({ fieldType: "date" });
147
+ expect(screen.getByPlaceholderText("YYYY-MM-DD")).toBeInTheDocument();
148
+ });
149
+
150
+ it("boolean: a Value select with True / False and no text input", async () => {
151
+ const { container } = renderEditor({ fieldType: "boolean" });
152
+ const triggers = selectTriggers(container);
153
+ expect(triggers).toHaveLength(2);
154
+ expect(textInputs(container)).toHaveLength(0);
155
+ expect(screen.getByText("Select...")).toBeInTheDocument();
156
+
157
+ const options = await openSelect(container, triggers[1]);
158
+ expect(optionLabels(options)).toEqual(["True", "False"]);
159
+ });
160
+
161
+ it("select: a Value select over the column's options", async () => {
162
+ const { container } = renderEditor({ fieldType: "select", options: statusOptions });
163
+ const triggers = selectTriggers(container);
164
+ expect(triggers).toHaveLength(2);
165
+ expect(textInputs(container)).toHaveLength(0);
166
+
167
+ const options = await openSelect(container, triggers[1]);
168
+ expect(optionLabels(options)).toEqual(statusOptions.map((option) => option.label));
169
+ });
170
+
171
+ it("select without options falls back to the text Input", () => {
172
+ const { container } = renderEditor({ fieldType: "select" });
173
+ expect(selectTriggers(container)).toHaveLength(1);
174
+ expect(screen.getByPlaceholderText("Enter value...")).toBeInTheDocument();
175
+ });
176
+
177
+ it("shows the current value", () => {
178
+ renderEditor({ fieldType: "text", value: "ada" });
179
+ expect((screen.getByPlaceholderText("Enter value...") as HTMLInputElement).value).toBe("ada");
180
+ });
181
+ });
182
+
183
+ describe("value-less operators", () => {
184
+ it.each<FilterOperator>(["isEmpty", "isNotEmpty"])(
185
+ "%s renders the Condition select only",
186
+ (operator) => {
187
+ const { container } = renderEditor({ fieldType: "text", operator });
188
+ expect(screen.getByText("Condition")).toBeInTheDocument();
189
+ expect(screen.queryByText("Value")).toBeNull();
190
+ expect(selectTriggers(container)).toHaveLength(1);
191
+ expect(textInputs(container)).toHaveLength(0);
192
+ },
193
+ );
194
+
195
+ it("switching from a value-less operator back to one with a value restores the input", async () => {
196
+ const onOperator = vi.fn();
197
+ const { container, rerender } = renderWithProviders(
198
+ <FilterOperatorValueEditor
199
+ fieldType="text"
200
+ operator="isEmpty"
201
+ value=""
202
+ onOperatorChange={onOperator}
203
+ onValueChange={vi.fn()}
204
+ />,
205
+ );
206
+ expect(textInputs(container)).toHaveLength(0);
207
+ rerender(
208
+ <FilterOperatorValueEditor
209
+ fieldType="text"
210
+ operator="contains"
211
+ value=""
212
+ onOperatorChange={onOperator}
213
+ onValueChange={vi.fn()}
214
+ />,
215
+ );
216
+ await waitFor(() => expect(textInputs(container)).toHaveLength(1));
217
+ });
218
+ });
219
+
220
+ describe("callbacks", () => {
221
+ it("typing reports the raw text through onValueChange", async () => {
222
+ const onValue = vi.fn();
223
+ renderWithProviders(
224
+ <ControlledEditor fieldType="text" onOperatorSpy={vi.fn()} onValueSpy={onValue} />,
225
+ );
226
+ const input = screen.getByPlaceholderText("Enter value...") as HTMLInputElement;
227
+ fireEvent.change(input, { target: { value: "ada" } });
228
+ await waitFor(() => expect(onValue).toHaveBeenLastCalledWith("ada"));
229
+ expect(input.value).toBe("ada");
230
+ });
231
+
232
+ it("picking a boolean value reports the stringified flag", async () => {
233
+ const onValue = vi.fn();
234
+ const { container } = renderWithProviders(
235
+ <ControlledEditor fieldType="boolean" onOperatorSpy={vi.fn()} onValueSpy={onValue} />,
236
+ );
237
+ const options = await openSelect(container, selectTriggers(container)[1]);
238
+ const no = options.find((option) => option.textContent?.trim() === "False");
239
+ expect(no).toBeDefined();
240
+ pickOption(no!);
241
+ await waitFor(() => expect(onValue).toHaveBeenLastCalledWith("false"));
242
+ });
243
+
244
+ it("picking a select value reports the option value, not its label", async () => {
245
+ const onValue = vi.fn();
246
+ const { container } = renderWithProviders(
247
+ <ControlledEditor
248
+ fieldType="select"
249
+ options={statusOptions}
250
+ onOperatorSpy={vi.fn()}
251
+ onValueSpy={onValue}
252
+ />,
253
+ );
254
+ const options = await openSelect(container, selectTriggers(container)[1]);
255
+ const inProgress = options.find((option) => option.textContent?.trim() === "In progress");
256
+ expect(inProgress).toBeDefined();
257
+ pickOption(inProgress!);
258
+ await waitFor(() => expect(onValue).toHaveBeenLastCalledWith("in-progress"));
259
+ });
260
+ });
261
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * FilterOperatorValueEditor — the ONE fieldtype-aware operator/value editor
3
+ * (LC-16). Both filter surfaces render it: the ColumnHeaderFilter funnel and
4
+ * the DataTableFilterButton toolbar panel. The surface owns commit semantics,
5
+ * so these stories exercise only what the editor owns: the operator
6
+ * vocabulary per field type, and the value editor each type gets.
7
+ */
8
+
9
+ import { action } from "@multiplatform.one/storybook";
10
+ import type { Meta } from "@storybook/react-native-web-vite";
11
+ import React, { useState } from "react";
12
+ import { Card, Text, XStack, YStack } from "tamagui";
13
+ import type { DataTableColumnType, FilterOperator } from "../../types";
14
+ import {
15
+ defaultOperatorForFilterType,
16
+ filterOperatorLabel,
17
+ operatorNeedsNoValue,
18
+ } from "../../utils/filterFields";
19
+ import { FilterOperatorValueEditor } from "./index";
20
+
21
+ const meta: Meta<typeof FilterOperatorValueEditor> = {
22
+ title: "Table/FilterOperatorValueEditor",
23
+ component: FilterOperatorValueEditor,
24
+ parameters: {
25
+ status: { type: "stable" },
26
+ docs: {
27
+ description: {
28
+ component:
29
+ "The shared operator + value editor behind every DataTable filter surface. " +
30
+ "The operator list and the value editor both follow the field type.",
31
+ },
32
+ },
33
+ },
34
+ };
35
+
36
+ export default meta;
37
+
38
+ const statusOptions = [
39
+ { label: "Open", value: "open" },
40
+ { label: "In progress", value: "in-progress" },
41
+ { label: "Done", value: "done" },
42
+ ];
43
+
44
+ const fieldTypeLabels: Record<DataTableColumnType, string> = {
45
+ text: "Text",
46
+ number: "Number",
47
+ date: "Date",
48
+ boolean: "Boolean",
49
+ select: "Select",
50
+ };
51
+
52
+ interface HarnessProps {
53
+ fieldType: DataTableColumnType;
54
+ options?: Array<{ label: string; value: any }>;
55
+ initialOperator?: FilterOperator;
56
+ initialValue?: string;
57
+ /** Show the field-type heading (the all-types board needs it). */
58
+ heading?: boolean;
59
+ }
60
+
61
+ /**
62
+ * Drives the editor the way both surfaces do: operator and value live in the
63
+ * caller's state, and the editor only reports changes. The card below is the
64
+ * filter the surface would commit.
65
+ */
66
+ function EditorHarness({
67
+ fieldType,
68
+ options,
69
+ initialOperator,
70
+ initialValue = "",
71
+ heading = false,
72
+ }: HarnessProps) {
73
+ const [operator, setOperator] = useState<FilterOperator>(
74
+ initialOperator ?? defaultOperatorForFilterType(fieldType),
75
+ );
76
+ const [value, setValue] = useState(initialValue);
77
+
78
+ const handleOperatorChange = (next: FilterOperator) => {
79
+ setOperator(next);
80
+ action("onOperatorChange")(next);
81
+ };
82
+ const handleValueChange = (next: string) => {
83
+ setValue(next);
84
+ action("onValueChange")(next);
85
+ };
86
+
87
+ const shownValue =
88
+ fieldType === "select" && options
89
+ ? (options.find((opt) => String(opt.value) === value)?.label ?? value)
90
+ : value;
91
+ const summary = operatorNeedsNoValue(operator)
92
+ ? filterOperatorLabel(operator)
93
+ : `${filterOperatorLabel(operator)} ${shownValue || "(no value)"}`;
94
+
95
+ return (
96
+ <YStack gap="$3" width={280}>
97
+ {heading && (
98
+ <Text fontSize="$3" fontWeight="600">
99
+ {fieldTypeLabels[fieldType]}
100
+ </Text>
101
+ )}
102
+ {/* Same vertical rhythm the funnel popover gives the editor. */}
103
+ <YStack gap="$2">
104
+ <FilterOperatorValueEditor
105
+ fieldType={fieldType}
106
+ operator={operator}
107
+ value={value}
108
+ onOperatorChange={handleOperatorChange}
109
+ onValueChange={handleValueChange}
110
+ options={options}
111
+ />
112
+ </YStack>
113
+ <Card padding="$3" borderRadius="$4">
114
+ <YStack gap="$1">
115
+ <Text fontSize="$2" color="$color10">
116
+ Filter the surface would commit
117
+ </Text>
118
+ <Text fontSize="$3">{summary}</Text>
119
+ </YStack>
120
+ </Card>
121
+ </YStack>
122
+ );
123
+ }
124
+
125
+ export const Default = () => (
126
+ <YStack padding="$4">
127
+ <EditorHarness fieldType="text" />
128
+ </YStack>
129
+ );
130
+ Default.storyName = "Main";
131
+
132
+ /** Text fields: contains / starts with / ends with plus the empty checks. */
133
+ export const TextField = () => (
134
+ <YStack padding="$4">
135
+ <EditorHarness fieldType="text" initialValue="ada" />
136
+ </YStack>
137
+ );
138
+
139
+ /** Number fields: comparison operators and a numeric keyboard on the value. */
140
+ export const NumberField = () => (
141
+ <YStack padding="$4">
142
+ <EditorHarness fieldType="number" initialOperator="isGreaterThan" initialValue="42" />
143
+ </YStack>
144
+ );
145
+
146
+ /** Date fields: before / after / on-or-before / on-or-after, typed as YYYY-MM-DD. */
147
+ export const DateField = () => (
148
+ <YStack padding="$4">
149
+ <EditorHarness fieldType="date" initialOperator="isAfter" initialValue="2026-01-01" />
150
+ </YStack>
151
+ );
152
+
153
+ /** Boolean fields: the value is a True / False select, not free text. */
154
+ export const BooleanField = () => (
155
+ <YStack padding="$4">
156
+ <EditorHarness fieldType="boolean" initialValue="true" />
157
+ </YStack>
158
+ );
159
+
160
+ /** Select fields: the value picks from the field's own options. */
161
+ export const SelectField = () => (
162
+ <YStack padding="$4">
163
+ <EditorHarness fieldType="select" options={statusOptions} initialValue="open" />
164
+ </YStack>
165
+ );
166
+
167
+ /** isEmpty / isNotEmpty carry no value, so the value editor is not rendered. */
168
+ export const NoValueOperator = () => (
169
+ <YStack padding="$4">
170
+ <EditorHarness fieldType="text" initialOperator="isEmpty" />
171
+ </YStack>
172
+ );
173
+
174
+ /** Every field type on one board, for theme-state verification. */
175
+ export const AllFieldTypes = () => (
176
+ <XStack padding="$4" gap="$6" flexWrap="wrap">
177
+ <EditorHarness fieldType="text" heading />
178
+ <EditorHarness fieldType="number" heading />
179
+ <EditorHarness fieldType="date" heading />
180
+ <EditorHarness fieldType="boolean" heading />
181
+ <EditorHarness fieldType="select" options={statusOptions} heading />
182
+ </XStack>
183
+ );
@@ -0,0 +1,88 @@
1
+ /**
2
+ * KnobDialog spec (MPO-114). The wrapper owns open/close plumbing and the
3
+ * knob-wired content surface. FilterManager specs cover the save-dialog copy.
4
+ */
5
+
6
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
7
+ import { fireEvent } from "@testing-library/react";
8
+ import { useState } from "react";
9
+ import { describe, expect, it, vi } from "vitest";
10
+ import { Button } from "@multiplatform.one/forms";
11
+ import {
12
+ KnobDialog,
13
+ KnobDialogClose,
14
+ KnobDialogContent,
15
+ KnobDialogDescription,
16
+ KnobDialogTitle,
17
+ KnobDialogTrigger,
18
+ } from "./KnobDialog";
19
+
20
+ /** Dialog portals into document.body — query there, not the RTL container. */
21
+ function root(): HTMLElement {
22
+ return document.body;
23
+ }
24
+
25
+ function DialogHarness({
26
+ startOpen = true,
27
+ onOpenChange,
28
+ }: {
29
+ startOpen?: boolean;
30
+ onOpenChange?: (open: boolean) => void;
31
+ }) {
32
+ const [open, setOpen] = useState(startOpen);
33
+ return (
34
+ <KnobDialog
35
+ open={open}
36
+ onOpenChange={(next) => {
37
+ setOpen(next);
38
+ onOpenChange?.(next);
39
+ }}
40
+ >
41
+ <KnobDialogTrigger asChild>
42
+ <Button>Open dialog</Button>
43
+ </KnobDialogTrigger>
44
+ <KnobDialogContent minWidth={400}>
45
+ <KnobDialogTitle>Save view</KnobDialogTitle>
46
+ <KnobDialogDescription>Name this filter set for later.</KnobDialogDescription>
47
+ <KnobDialogClose asChild>
48
+ <Button>Close</Button>
49
+ </KnobDialogClose>
50
+ </KnobDialogContent>
51
+ </KnobDialog>
52
+ );
53
+ }
54
+
55
+ describe("KnobDialog", () => {
56
+ it("open state portals title and description into the document", () => {
57
+ renderWithProviders(<DialogHarness startOpen />);
58
+ expect(root().textContent).toContain("Save view");
59
+ expect(root().textContent).toContain("Name this filter set for later.");
60
+ });
61
+
62
+ it("closed state does not render the dialog body", () => {
63
+ renderWithProviders(<DialogHarness startOpen={false} />);
64
+ expect(root().textContent).not.toContain("Save view");
65
+ expect(root().textContent).toContain("Open dialog");
66
+ });
67
+
68
+ it("paints an overlay while open", () => {
69
+ renderWithProviders(<DialogHarness startOpen />);
70
+ const overlay =
71
+ root().querySelector("[data-state]") ?? root().querySelector("[class*='Overlay']");
72
+ // Tamagui Dialog.Overlay is in the portal tree whenever the dialog is open.
73
+ const portalBits = Array.from(root().querySelectorAll("[data-state='open']"));
74
+ expect(portalBits.length).toBeGreaterThan(0);
75
+ expect(overlay || portalBits[0]).toBeTruthy();
76
+ });
77
+
78
+ it("Close reports onOpenChange(false)", () => {
79
+ const onOpenChange = vi.fn();
80
+ renderWithProviders(<DialogHarness startOpen onOpenChange={onOpenChange} />);
81
+ const close = Array.from(root().querySelectorAll('[role="button"]')).find((el) =>
82
+ el.textContent?.includes("Close"),
83
+ );
84
+ expect(close).toBeTruthy();
85
+ fireEvent.click(close!);
86
+ expect(onOpenChange).toHaveBeenCalledWith(false);
87
+ });
88
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * KnobDialog — Tamagui Dialog with table knobs on the content surface
3
+ * (borderRadius, elevation, gap). FilterManager is the production consumer.
4
+ * These boards show the wrapper itself, open and closed.
5
+ */
6
+
7
+ import type { Meta } from "@storybook/react-native-web-vite";
8
+ import { useState } from "react";
9
+ import { Button } from "@multiplatform.one/forms";
10
+ import { Paragraph, XStack, YStack } from "tamagui";
11
+ import {
12
+ KnobDialog,
13
+ KnobDialogClose,
14
+ KnobDialogContent,
15
+ KnobDialogDescription,
16
+ KnobDialogTitle,
17
+ KnobDialogTrigger,
18
+ } from "./KnobDialog";
19
+
20
+ const meta: Meta = {
21
+ title: "Table/KnobDialog",
22
+ parameters: {
23
+ status: { type: "stable" },
24
+ docs: {
25
+ description: {
26
+ component:
27
+ "Internal table Dialog wrapper. Content inherits container radius, elevation " +
28
+ "and gap from useResolvedKnobs so FilterManager chrome matches the rest of the table.",
29
+ },
30
+ },
31
+ },
32
+ };
33
+
34
+ export default meta;
35
+
36
+ function DialogHarness({ startOpen = true }: { startOpen?: boolean }) {
37
+ const [open, setOpen] = useState(startOpen);
38
+ return (
39
+ <YStack minHeight={400} padding="$4" alignItems="flex-start">
40
+ <KnobDialog open={open} onOpenChange={setOpen}>
41
+ <KnobDialogTrigger asChild>
42
+ <Button>Open dialog</Button>
43
+ </KnobDialogTrigger>
44
+ <KnobDialogContent minWidth={400}>
45
+ <KnobDialogTitle>Save view</KnobDialogTitle>
46
+ <KnobDialogDescription>Name this filter set for later.</KnobDialogDescription>
47
+ <Paragraph>Short dialog body.</Paragraph>
48
+ <XStack justifyContent="flex-end">
49
+ <KnobDialogClose asChild>
50
+ <Button>Close</Button>
51
+ </KnobDialogClose>
52
+ </XStack>
53
+ </KnobDialogContent>
54
+ </KnobDialog>
55
+ </YStack>
56
+ );
57
+ }
58
+
59
+ export const Default = {
60
+ name: "Main",
61
+ render: () => <DialogHarness />,
62
+ };
63
+
64
+ export const Closed = {
65
+ render: () => <DialogHarness startOpen={false} />,
66
+ };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * SavedFilterDropdown — picker over persisted filter records. Uses the
3
+ * forms Select trigger (same FloatingPanel contract as every other Select
4
+ * in the catalog). Renders nothing when the list is empty.
5
+ */
6
+
7
+ import { action } from "@multiplatform.one/storybook";
8
+ import type { Meta } from "@storybook/react-native-web-vite";
9
+ import { useState } from "react";
10
+ import { YStack } from "tamagui";
11
+ import type { ParsedFilter } from "../hooks/useSavedFilters";
12
+ import { SavedFilterDropdown } from "./SavedFilterDropdown";
13
+
14
+ const meta: Meta<typeof SavedFilterDropdown> = {
15
+ title: "Table/SavedFilterDropdown",
16
+ component: SavedFilterDropdown,
17
+ parameters: {
18
+ status: { type: "stable" },
19
+ docs: {
20
+ description: {
21
+ component:
22
+ "Select over saved filter records. Global filters are labelled in the option " +
23
+ "text. The empty list is a silent no-op so toolbars do not grow a dead trigger.",
24
+ },
25
+ },
26
+ },
27
+ };
28
+
29
+ export default meta;
30
+
31
+ const savedFilters: ParsedFilter[] = [
32
+ {
33
+ name: "Engineering Only",
34
+ filter_name: "Engineering Only",
35
+ for_user: "admin",
36
+ filters: [["department", "=", "Engineering"]],
37
+ isGlobal: false,
38
+ },
39
+ {
40
+ name: "High Salary",
41
+ filter_name: "High Salary",
42
+ for_user: "",
43
+ filters: [["salary", ">", 100000]],
44
+ isGlobal: true,
45
+ },
46
+ {
47
+ name: "Recent hires",
48
+ filter_name: "Recent hires",
49
+ for_user: "admin",
50
+ filters: [["joinDate", "after", "2024-01-01"]],
51
+ isGlobal: false,
52
+ },
53
+ ];
54
+
55
+ function DropdownHarness({
56
+ initial,
57
+ disabled = false,
58
+ }: {
59
+ initial?: ParsedFilter | null;
60
+ disabled?: boolean;
61
+ }) {
62
+ const [selected, setSelected] = useState<ParsedFilter | null>(initial ?? null);
63
+ return (
64
+ <YStack padding="$4" alignItems="flex-start" width={280}>
65
+ <SavedFilterDropdown
66
+ filters={savedFilters}
67
+ selectedFilter={selected}
68
+ disabled={disabled}
69
+ onSelectFilter={(next) => {
70
+ setSelected(next);
71
+ action("onSelectFilter")(next);
72
+ }}
73
+ onDeleteFilter={(name) => action("onDeleteFilter")(name)}
74
+ />
75
+ </YStack>
76
+ );
77
+ }
78
+
79
+ export const Default = {
80
+ name: "Main",
81
+ render: () => <DropdownHarness />,
82
+ };
83
+
84
+ export const Selected = {
85
+ render: () => <DropdownHarness initial={savedFilters[0]} />,
86
+ };
87
+
88
+ export const Disabled = {
89
+ render: () => <DropdownHarness initial={savedFilters[0]} disabled />,
90
+ };