@multiplatform.one/table 7.7.0 → 7.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/table",
3
- "version": "7.7.0",
3
+ "version": "7.7.1",
4
4
  "description": "Advanced table components with filtering, pagination, sorting, and URL state management",
5
5
  "keywords": [
6
6
  "cross-platform",
@@ -80,13 +80,13 @@
80
80
  "@tanstack/react-table": "^8.21.3",
81
81
  "@tanstack/table-core": "^8.21.3",
82
82
  "tamagui": "2.7.6",
83
- "@multiplatform.one/components": "7.7.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"
83
+ "@multiplatform.one/components": "7.7.1",
84
+ "@multiplatform.one/forms": "7.7.1",
85
+ "@multiplatform.one/platform": "7.7.1",
86
+ "@multiplatform.one/rich-text": "7.7.1",
87
+ "@multiplatform.one/router": "7.7.1",
88
+ "@multiplatform.one/table-primitives": "7.7.1",
89
+ "@multiplatform.one/theme": "7.7.1"
90
90
  },
91
91
  "devDependencies": {
92
92
  "@tamagui/build": "2.7.6",
@@ -98,10 +98,10 @@
98
98
  "react": "19.2.5",
99
99
  "react-dom": "19.2.5",
100
100
  "vitest": "^4.1.5",
101
- "@multiplatform.one/config": "7.7.0",
102
- "@multiplatform.one/router": "7.7.0",
103
- "@multiplatform.one/storybook": "7.7.0",
104
- "@multiplatform.one/test-utils": "7.7.0"
101
+ "@multiplatform.one/config": "7.7.1",
102
+ "@multiplatform.one/router": "7.7.1",
103
+ "@multiplatform.one/storybook": "7.7.1",
104
+ "@multiplatform.one/test-utils": "7.7.1"
105
105
  },
106
106
  "peerDependencies": {
107
107
  "i18next": "^25.0.0",
@@ -328,10 +328,11 @@ describe("DataTable", () => {
328
328
  expect(screen.getByText("John")).toBeDefined();
329
329
  expect(screen.getByText("Jane")).toBeDefined();
330
330
 
331
- // The forms SearchInput default placeholder is bidi-isolated (LC-58,
332
- // FSI…PDI) — match the isolated form, same as the DataTablePagination
333
- // range-label assertions.
334
- await user.type(screen.getByPlaceholderText("\u2068Search...\u2069"), "Charlie");
331
+ // DataTableToolbar passes an explicit `placeholder={t("Search")}`, so
332
+ // the forms SearchInput default (bidi-isolated "Search…") never reaches
333
+ // this input. Query the stable aria-label instead of a literal
334
+ // placeholder string.
335
+ await user.type(screen.getByLabelText("Search table data"), "Charlie");
335
336
 
336
337
  await waitFor(() => {
337
338
  expect(screen.queryByText("John")).toBeNull();
@@ -656,7 +657,10 @@ describe("DataTable", () => {
656
657
  );
657
658
 
658
659
  expect(screen.queryByLabelText("Select all rows")).toBeNull();
659
- expect(screen.queryByLabelText(/^Select row/)).toBeNull();
660
+ // Anchored on the row-name template, not the bare "Select row" prefix:
661
+ // DataTablePagination's page-size control is labelled "Select rows per
662
+ // page", which /^Select row/ also matches.
663
+ expect(screen.queryByLabelText(/^Select row \S/)).toBeNull();
660
664
  expect(screen.queryByText(/selected/)).toBeNull();
661
665
  });
662
666
 
@@ -673,8 +677,10 @@ describe("DataTable", () => {
673
677
 
674
678
  expect(screen.getByLabelText("Select all rows")).toBeDefined();
675
679
  // Row identity is templated into the name (DG-A11Y-06); without an
676
- // initialized i18n instance the raw key renders, so match the prefix.
677
- expect(screen.getAllByLabelText(/^Select row/).length).toBe(sampleData.length);
680
+ // initialized i18n instance the raw key renders, so match the prefix —
681
+ // but require a following token so the pagination page-size control
682
+ // ("Select rows per page") is not counted as a sixth row.
683
+ expect(screen.getAllByLabelText(/^Select row \S/).length).toBe(sampleData.length);
678
684
  });
679
685
 
680
686
  it("auto-shows selection toolbar with count and clear after selecting a row", async () => {
@@ -0,0 +1,116 @@
1
+ /**
2
+ * DataTableCardControls — the sort + filter row above the card list.
3
+ *
4
+ * The grid puts sorting and per-column filtering in the column headers. The
5
+ * card layout has no headers, so this toolbar restores both: a sort menu that
6
+ * cycles none → asc → desc per sortable column (the same cycle as a header
7
+ * click) and the compact DataTableFilters chrome. Both drive the same table
8
+ * state as the grid — there is no parallel state.
9
+ *
10
+ * It only exists in `layout="cards"`, which is why these stories pin the
11
+ * layout instead of relying on the viewport.
12
+ */
13
+
14
+ import type { Meta } from "@storybook/react-native-web-vite";
15
+ import { Paragraph, YStack } from "tamagui";
16
+ import type { DataTableColumn } from "../../types";
17
+ import { DataTable } from "./index";
18
+
19
+ const meta: Meta = {
20
+ title: "Table/DataTableCardControls",
21
+ parameters: {
22
+ status: { type: "stable" },
23
+ docs: {
24
+ description: {
25
+ component:
26
+ "Sort menu plus filter chrome above the card list, standing in for the grid's " +
27
+ "column headers. Renders only in the cards layout, and only when sorting or " +
28
+ "filtering is enabled.",
29
+ },
30
+ },
31
+ },
32
+ };
33
+
34
+ export default meta;
35
+
36
+ interface Person {
37
+ id: number;
38
+ firstName: string;
39
+ lastName: string;
40
+ age: number;
41
+ email: string;
42
+ }
43
+
44
+ const people: Person[] = [
45
+ { id: 1, firstName: "John", lastName: "Doe", age: 28, email: "john@example.com" },
46
+ { id: 2, firstName: "Jane", lastName: "Smith", age: 32, email: "jane@example.com" },
47
+ { id: 3, firstName: "Bob", lastName: "Johnson", age: 45, email: "bob@example.com" },
48
+ { id: 4, firstName: "Alice", lastName: "Williams", age: 22, email: "alice@example.com" },
49
+ ];
50
+
51
+ const columns: DataTableColumn<Person>[] = [
52
+ { accessorKey: "firstName", header: "First Name" },
53
+ { accessorKey: "lastName", header: "Last Name" },
54
+ { accessorKey: "age", header: "Age" },
55
+ { accessorKey: "email", header: "Email" },
56
+ ];
57
+
58
+ function CardTable(props: Record<string, any>) {
59
+ return (
60
+ <YStack padding="$4" width="100%" maxWidth={520}>
61
+ <DataTable
62
+ data={people}
63
+ columns={columns}
64
+ layout="cards"
65
+ aria-label="People as cards"
66
+ {...props}
67
+ />
68
+ </YStack>
69
+ );
70
+ }
71
+
72
+ /** Both affordances: Sort menu on the left, Add Filter chrome beside it. */
73
+ export const Default = {
74
+ name: "Main",
75
+ render: () => <CardTable enableSorting enableFiltering />,
76
+ };
77
+
78
+ /** Sorting only — no filter chrome in the row. */
79
+ export const SortOnly = {
80
+ render: () => <CardTable enableSorting enableFiltering={false} />,
81
+ };
82
+
83
+ /** Filtering only — no Sort menu in the row. */
84
+ export const FilterOnly = {
85
+ render: () => <CardTable enableSorting={false} enableFiltering />,
86
+ };
87
+
88
+ /**
89
+ * With a sort already applied the button reads the column and direction
90
+ * (`Age ↑`) instead of the bare "Sort", so the card list is never sorted
91
+ * invisibly.
92
+ */
93
+ export const ActiveSort = {
94
+ render: () => (
95
+ <CardTable
96
+ enableSorting
97
+ enableFiltering
98
+ initialState={{ sorting: [{ id: "age", desc: false }] }}
99
+ />
100
+ ),
101
+ };
102
+
103
+ /** UX-011: the whole table disabled renders the controls inert and LC-40 visible. */
104
+ export const Disabled = {
105
+ render: () => <CardTable enableSorting enableFiltering disabled />,
106
+ };
107
+
108
+ /** Neither enabled: the toolbar is absent, not an empty bar taking vertical space. */
109
+ export const NoControls = {
110
+ render: () => (
111
+ <YStack padding="$4" gap="$2" width="100%" maxWidth={520}>
112
+ <Paragraph size="$2">Sorting and filtering off — no control row above the cards.</Paragraph>
113
+ <CardTable enableSorting={false} enableFiltering={false} />
114
+ </YStack>
115
+ ),
116
+ };
@@ -0,0 +1,132 @@
1
+ /**
2
+ * DataTableDocumentList — subject-led document rows for `layout="list"`.
3
+ *
4
+ * This is the desk List view, not a grid and not cards. Each document gets one
5
+ * horizontal row: checkbox, then a title with its meta stacked underneath in
6
+ * muted text, then a trailing indicator cluster, then actions. No column
7
+ * headers, no key-value labels, no card frame, and no wrapping into a column
8
+ * grid (Polaris Index / Frappe list).
9
+ *
10
+ * Slots are assigned by column, not by position: `meta.role` of `title`,
11
+ * `indicator`, `actions` or `hidden` decides, and status-shaped ids
12
+ * (status, priority, workflow_state, state) and option-group fields fall into
13
+ * the indicator cluster on their own.
14
+ */
15
+
16
+ import type { Meta } from "@storybook/react-native-web-vite";
17
+ import { YStack } from "tamagui";
18
+ import type { DataTableColumn } from "../../types";
19
+ import { DataTable } from "./index";
20
+
21
+ const meta: Meta = {
22
+ title: "Table/DataTableDocumentList",
23
+ parameters: {
24
+ status: { type: "stable" },
25
+ docs: {
26
+ description: {
27
+ component:
28
+ "One row per document: title plus stacked muted meta, a trailing indicator cluster, " +
29
+ "then actions. Stacked-group radius rounds the first and last rows only.",
30
+ },
31
+ },
32
+ },
33
+ };
34
+
35
+ export default meta;
36
+
37
+ interface Task {
38
+ id: number;
39
+ title: string;
40
+ owner: string;
41
+ due: string;
42
+ status: string;
43
+ }
44
+
45
+ const tasks: Task[] = [
46
+ {
47
+ id: 1,
48
+ title: "Ship the card layout",
49
+ owner: "Ada Lovelace",
50
+ due: "2026-09-08",
51
+ status: "Open",
52
+ },
53
+ {
54
+ id: 2,
55
+ title: "Rewire the filter chips",
56
+ owner: "Grace Hopper",
57
+ due: "2026-09-10",
58
+ status: "In progress",
59
+ },
60
+ {
61
+ id: 3,
62
+ title: "Retire the legacy grid",
63
+ owner: "Alan Turing",
64
+ due: "2026-09-15",
65
+ status: "Blocked",
66
+ },
67
+ {
68
+ id: 4,
69
+ title: "Document the slot taxonomy",
70
+ owner: "Katherine Johnson",
71
+ due: "2026-09-20",
72
+ status: "Open",
73
+ },
74
+ ];
75
+
76
+ /** Roles are explicit: title leads, status is the trailing indicator. */
77
+ const columns: DataTableColumn<Task>[] = [
78
+ { accessorKey: "title", header: "Title", meta: { role: "title" } },
79
+ { accessorKey: "owner", header: "Owner" },
80
+ { accessorKey: "due", header: "Due" },
81
+ { accessorKey: "status", header: "Status", meta: { role: "indicator" } },
82
+ ];
83
+
84
+ function ListTable(props: Record<string, any>) {
85
+ return (
86
+ <YStack padding="$4" width="100%" maxWidth={720}>
87
+ <DataTable
88
+ data={tasks}
89
+ columns={columns}
90
+ layout="list"
91
+ aria-label="Tasks as a document list"
92
+ {...props}
93
+ />
94
+ </YStack>
95
+ );
96
+ }
97
+
98
+ /** Title leads, owner and due stack beneath it, status rides the right edge. */
99
+ export const Default = {
100
+ name: "Main",
101
+ render: () => <ListTable />,
102
+ };
103
+
104
+ /** Selection puts a checkbox at the head of every row. */
105
+ export const Selection = {
106
+ render: () => <ListTable enableRowSelection />,
107
+ };
108
+
109
+ /**
110
+ * No `meta.role` anywhere: the first non-action column becomes the title and
111
+ * `status` still lands in the indicator cluster on its id alone.
112
+ */
113
+ export const InferredSlots = {
114
+ render: () => (
115
+ <ListTable
116
+ columns={[
117
+ { accessorKey: "title", header: "Title" },
118
+ { accessorKey: "owner", header: "Owner" },
119
+ { accessorKey: "status", header: "Status" },
120
+ ]}
121
+ />
122
+ ),
123
+ };
124
+
125
+ /** No rows and no filters: the list paints "No data available", not blank. */
126
+ export const Empty = {
127
+ render: () => (
128
+ <YStack padding="$4" width="100%" maxWidth={720}>
129
+ <DataTable data={[]} columns={columns} layout="list" aria-label="Empty document list" />
130
+ </YStack>
131
+ ),
132
+ };
@@ -0,0 +1,124 @@
1
+ /**
2
+ * DataTableMobileCards — the stacked card body for `layout="cards"`.
3
+ *
4
+ * Below `$sm` (or forced) a DataTable stops being a grid: each row becomes a
5
+ * card whose first visible column is the title and whose remaining columns
6
+ * become label/value rows. Column visibility, order, sorting and filtering all
7
+ * come from the same table state as the grid — the cells run through the same
8
+ * renderers inside `TableCellContext`, so fields stay chromeless.
9
+ *
10
+ * Selection, row numbers and delete live in the card header beside the title.
11
+ */
12
+
13
+ import type { Meta } from "@storybook/react-native-web-vite";
14
+ import { useState } from "react";
15
+ import { YStack } from "tamagui";
16
+ import type { DataTableColumn } from "../../types";
17
+ import { DataTable } from "./index";
18
+
19
+ const meta: Meta = {
20
+ title: "Table/DataTableMobileCards",
21
+ parameters: {
22
+ status: { type: "stable" },
23
+ docs: {
24
+ description: {
25
+ component:
26
+ "One card per row: title in the header, remaining columns as label/value rows. " +
27
+ "Select-all sits above the stack; per-row select, row number and delete sit in the card header.",
28
+ },
29
+ },
30
+ },
31
+ };
32
+
33
+ export default meta;
34
+
35
+ interface Person {
36
+ id: number;
37
+ firstName: string;
38
+ lastName: string;
39
+ age: number;
40
+ email: string;
41
+ }
42
+
43
+ const people: Person[] = [
44
+ { id: 1, firstName: "John", lastName: "Doe", age: 28, email: "john@example.com" },
45
+ { id: 2, firstName: "Jane", lastName: "Smith", age: 32, email: "jane@example.com" },
46
+ { id: 3, firstName: "Bob", lastName: "Johnson", age: 45, email: "bob@example.com" },
47
+ { id: 4, firstName: "Alice", lastName: "Williams", age: 22, email: "alice@example.com" },
48
+ ];
49
+
50
+ const columns: DataTableColumn<Person>[] = [
51
+ { accessorKey: "firstName", header: "First Name" },
52
+ { accessorKey: "lastName", header: "Last Name" },
53
+ { accessorKey: "age", header: "Age" },
54
+ { accessorKey: "email", header: "Email" },
55
+ ];
56
+
57
+ function CardTable(props: Record<string, any>) {
58
+ return (
59
+ <YStack padding="$4" width="100%" maxWidth={480}>
60
+ <DataTable
61
+ data={people}
62
+ columns={columns}
63
+ layout="cards"
64
+ aria-label="People as cards"
65
+ {...props}
66
+ />
67
+ </YStack>
68
+ );
69
+ }
70
+
71
+ /** Card anatomy: title header, then one label/value row per remaining column. */
72
+ export const Default = {
73
+ name: "Main",
74
+ render: () => <CardTable />,
75
+ };
76
+
77
+ /** Select-all above the stack, a checkbox in every card header. */
78
+ export const Selection = {
79
+ render: () => <CardTable enableRowSelection />,
80
+ };
81
+
82
+ function DeletableCards() {
83
+ const [rows, setRows] = useState(people);
84
+ return (
85
+ <YStack padding="$4" width="100%" maxWidth={480}>
86
+ <DataTable
87
+ data={rows}
88
+ columns={columns}
89
+ layout="cards"
90
+ showRowNumbers
91
+ showDeleteRow
92
+ minRows={1}
93
+ onDataChange={(next: Person[]) => setRows(next)}
94
+ aria-label="People as deletable cards"
95
+ />
96
+ </YStack>
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Row numbers and the delete affordance share the card header with the title.
102
+ * The parent owns `data` — the card asks via onDataChange, it does not splice.
103
+ */
104
+ export const RowChrome = {
105
+ render: () => <DeletableCards />,
106
+ };
107
+
108
+ /** A single visible column: the header carries the title and there are no field rows. */
109
+ export const TitleOnly = {
110
+ render: () => (
111
+ <CardTable
112
+ columns={[{ accessorKey: "firstName", header: "First Name" } as DataTableColumn<Person>]}
113
+ />
114
+ ),
115
+ };
116
+
117
+ /** No rows and no filters: the empty state reads "No data available". */
118
+ export const Empty = {
119
+ render: () => (
120
+ <YStack padding="$4" width="100%" maxWidth={480}>
121
+ <DataTable data={[]} columns={columns} layout="cards" aria-label="Empty card list" />
122
+ </YStack>
123
+ ),
124
+ };
@@ -214,7 +214,10 @@ describe("chrome interplay: Search × injected filters × enableFiltering", () =
214
214
  initialState={{ filters: injectedReadyFilter }}
215
215
  />,
216
216
  );
217
- expect(screen.getByText("Add Filter")).toBeInTheDocument();
217
+ // The control renders its active-filter count in the label text
218
+ // ("Add Filter (1)"), so an exact getByText no longer matches. The
219
+ // aria-label is the stable handle, as the sibling arms above use.
220
+ expect(screen.getByLabelText("Add Filter")).toBeInTheDocument();
218
221
  control.unmount();
219
222
 
220
223
  renderWithProviders(
@@ -0,0 +1,99 @@
1
+ /**
2
+ * DataTableCardControls specs — the control row's OWN contract.
3
+ *
4
+ * cardLayout.spec.tsx already drives the sort menu through the card list and
5
+ * proves the cycle reorders cards. What nothing pinned is the row itself: that
6
+ * it is a labelled toolbar, that it disappears entirely when neither affordance
7
+ * is enabled (rather than rendering an empty bar), that each affordance can be
8
+ * turned off independently, and that the trigger reports the ACTIVE sort so a
9
+ * card list is never sorted invisibly.
10
+ */
11
+
12
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
13
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
14
+ import { describe, expect, it, vi } from "vitest";
15
+ import type { DataTableColumn } from "../../types";
16
+ import { DataTable } from "./index";
17
+
18
+ type Person = {
19
+ id: number;
20
+ firstName: string;
21
+ age: number;
22
+ email: string;
23
+ };
24
+
25
+ const people: Person[] = [
26
+ { id: 1, firstName: "John", age: 28, email: "john@example.com" },
27
+ { id: 2, firstName: "Jane", age: 32, email: "jane@example.com" },
28
+ { id: 3, firstName: "Bob", age: 45, email: "bob@example.com" },
29
+ ];
30
+
31
+ const columns: DataTableColumn<Person>[] = [
32
+ { accessorKey: "firstName", header: "First Name" },
33
+ { accessorKey: "age", header: "Age" },
34
+ ];
35
+
36
+ function renderCards(props: Record<string, any> = {}) {
37
+ return renderWithProviders(
38
+ <DataTable data={people} columns={columns} layout="cards" onStateChange={vi.fn()} {...props} />,
39
+ );
40
+ }
41
+
42
+ describe("DataTableCardControls", () => {
43
+ it("is a labelled toolbar above the card list", () => {
44
+ renderCards({ enableSorting: true, enableFiltering: true });
45
+ const toolbar = screen.getByRole("toolbar", { name: "Card list controls" });
46
+ expect(toolbar).toBeTruthy();
47
+ expect(screen.getByLabelText("Sort rows")).toBeTruthy();
48
+ });
49
+
50
+ it("renders no toolbar at all when neither sorting nor filtering is enabled", () => {
51
+ renderCards({ enableSorting: false, enableFiltering: false });
52
+ expect(screen.queryByRole("toolbar", { name: "Card list controls" })).toBeNull();
53
+ });
54
+
55
+ it("sorting only: the sort menu is there, the filter chrome is not", () => {
56
+ renderCards({ enableSorting: true, enableFiltering: false });
57
+ expect(screen.getByLabelText("Sort rows")).toBeTruthy();
58
+ expect(screen.queryByText("Add Filter")).toBeNull();
59
+ });
60
+
61
+ it("filtering only: the filter chrome is there, the sort menu is not", () => {
62
+ renderCards({ enableSorting: false, enableFiltering: true });
63
+ expect(screen.queryByLabelText("Sort rows")).toBeNull();
64
+ expect(screen.getByText("Add Filter")).toBeTruthy();
65
+ });
66
+
67
+ it("with no sort applied the trigger reads the neutral label", () => {
68
+ renderCards({ enableSorting: true });
69
+ expect(screen.getByLabelText("Sort rows").textContent).toContain("Sort");
70
+ expect(screen.getByLabelText("Sort rows").textContent).not.toContain("Age");
71
+ });
72
+
73
+ it("an active ascending sort is named on the trigger, arrow and all", () => {
74
+ renderCards({ enableSorting: true, initialState: { sorting: [{ id: "age", desc: false }] } });
75
+ expect(screen.getByLabelText("Sort rows").textContent).toContain("Age ↑");
76
+ });
77
+
78
+ it("an active descending sort flips the arrow on the trigger", () => {
79
+ renderCards({ enableSorting: true, initialState: { sorting: [{ id: "age", desc: true }] } });
80
+ expect(screen.getByLabelText("Sort rows").textContent).toContain("Age ↓");
81
+ });
82
+
83
+ it("picking a column from the menu updates the trigger label", async () => {
84
+ renderCards({ enableSorting: true });
85
+ fireEvent.click(screen.getByLabelText("Sort rows"));
86
+ fireEvent.click(await screen.findByLabelText("Sort by Age"));
87
+ await waitFor(() => {
88
+ expect(screen.getByLabelText("Sort rows").textContent).toContain("Age ↑");
89
+ });
90
+ });
91
+
92
+ it("offers one menu entry per sortable column and no more", async () => {
93
+ renderCards({ enableSorting: true });
94
+ fireEvent.click(screen.getByLabelText("Sort rows"));
95
+ expect(await screen.findByLabelText("Sort by First Name")).toBeTruthy();
96
+ expect(screen.getByLabelText("Sort by Age")).toBeTruthy();
97
+ expect(screen.queryByLabelText("Sort by Email")).toBeNull();
98
+ });
99
+ });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * DataTableDocumentList specs — the list body's OWN contract.
3
+ *
4
+ * cardLayout.spec.tsx proves layout=list picks this body over the grid and the
5
+ * cards, that the title slot honours meta.role, and that rows are a nowrap flex
6
+ * list rather than a column grid. What no spec pinned is the SLOT TAXONOMY the
7
+ * list is built on (documentListSlots.ts): which column lands in the trailing
8
+ * indicator cluster, which becomes muted meta, which is dropped outright, and
9
+ * what happens when nothing declares a role at all. Plus the row head
10
+ * (selection) and the two empty-state copies.
11
+ */
12
+
13
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
14
+ import { screen, waitFor } from "@testing-library/react";
15
+ import { describe, expect, it, vi } from "vitest";
16
+ import type { DataTableColumn, TableFilter } from "../../types";
17
+ import { DataTable } from "./index";
18
+
19
+ type Task = {
20
+ id: number;
21
+ title: string;
22
+ owner: string;
23
+ status: string;
24
+ };
25
+
26
+ const tasks: Task[] = [
27
+ { id: 1, title: "Ship the card layout", owner: "Ada", status: "Open" },
28
+ { id: 2, title: "Rewire the chips", owner: "Grace", status: "Blocked" },
29
+ ];
30
+
31
+ const columns: DataTableColumn<Task>[] = [
32
+ { accessorKey: "title", header: "Title", meta: { role: "title" } },
33
+ { accessorKey: "owner", header: "Owner" },
34
+ { accessorKey: "status", header: "Status", meta: { role: "indicator" } },
35
+ ];
36
+
37
+ function renderList(props: Record<string, any> = {}) {
38
+ return renderWithProviders(
39
+ <DataTable data={tasks} columns={columns} layout="list" onStateChange={vi.fn()} {...props} />,
40
+ );
41
+ }
42
+
43
+ const slots = (container: HTMLElement, slot: string) =>
44
+ Array.from(container.querySelectorAll(`[data-list-slot="${slot}"]`)).map((n) => n.textContent);
45
+
46
+ describe("DataTableDocumentList", () => {
47
+ it("renders one row per document inside the list container", () => {
48
+ const { container } = renderList();
49
+ expect(container.querySelector('[data-testid="data-table-list"]')).not.toBeNull();
50
+ expect(container.querySelectorAll('[data-testid="data-table-list-row"]').length).toBe(
51
+ tasks.length,
52
+ );
53
+ });
54
+
55
+ it("splits a row into title, muted meta and a trailing indicator", () => {
56
+ const { container } = renderList();
57
+ expect(slots(container, "title")[0]).toBe("Ship the card layout");
58
+ expect(slots(container, "meta")).toContain("Ada");
59
+ expect(slots(container, "indicator")).toContain("Open");
60
+ });
61
+
62
+ it("with no roles declared the first content column leads and status still indicates", () => {
63
+ const { container } = renderList({
64
+ columns: [
65
+ { accessorKey: "title", header: "Title" },
66
+ { accessorKey: "owner", header: "Owner" },
67
+ { accessorKey: "status", header: "Status" },
68
+ ],
69
+ });
70
+ expect(slots(container, "title")[0]).toBe("Ship the card layout");
71
+ expect(slots(container, "meta")).toContain("Ada");
72
+ // `status` reaches the indicator cluster on its id alone, no meta.role.
73
+ expect(slots(container, "indicator")).toContain("Open");
74
+ });
75
+
76
+ it("an underscore-prefixed column is an action, never muted meta", () => {
77
+ const { container } = renderList({
78
+ columns: [
79
+ { accessorKey: "title", header: "Title", meta: { role: "title" } },
80
+ { id: "_liked_by", accessorKey: "owner", header: "♥", meta: { label: "Liked" } },
81
+ ],
82
+ });
83
+ expect(slots(container, "actions")).toContain("Ada");
84
+ expect(slots(container, "meta")).not.toContain("Ada");
85
+ });
86
+
87
+ it("a hidden-role column is dropped from every slot, not merely styled down", () => {
88
+ const { container } = renderList({
89
+ columns: [
90
+ { accessorKey: "title", header: "Title", meta: { role: "title" } },
91
+ { accessorKey: "owner", header: "Owner", meta: { role: "hidden" } },
92
+ ],
93
+ });
94
+ expect(slots(container, "title")[0]).toBe("Ship the card layout");
95
+ expect(slots(container, "meta")).not.toContain("Ada");
96
+ expect(slots(container, "indicator")).not.toContain("Ada");
97
+ expect(slots(container, "actions")).not.toContain("Ada");
98
+ });
99
+
100
+ it("a list is not a form: column headers never reprint as row labels", () => {
101
+ renderList();
102
+ expect(screen.queryByText("Owner")).toBeNull();
103
+ expect(screen.queryByText("Status")).toBeNull();
104
+ });
105
+
106
+ it("selection puts a named checkbox at the head of every row", () => {
107
+ renderList({ enableRowSelection: true });
108
+ expect(screen.getAllByLabelText(/^Select row /).length).toBe(tasks.length);
109
+ });
110
+
111
+ it("empty with no filters reads 'No data available'", () => {
112
+ renderWithProviders(
113
+ <DataTable data={[]} columns={columns} layout="list" onStateChange={vi.fn()} />,
114
+ );
115
+ expect(screen.getByText("No data available")).toBeTruthy();
116
+ });
117
+
118
+ it("empty because a filter matched nothing reads 'No results found'", async () => {
119
+ const noMatch: TableFilter[] = [
120
+ {
121
+ id: "flt-owner",
122
+ columnId: "owner",
123
+ value: { operator: "contains", value: "zzzz" },
124
+ label: "Owner",
125
+ },
126
+ ];
127
+ const { container } = renderWithProviders(
128
+ <DataTable
129
+ data={tasks}
130
+ columns={columns}
131
+ layout="list"
132
+ enableFiltering
133
+ filterableFields={[{ id: "owner", label: "Owner", type: "text" }]}
134
+ initialState={{ filters: noMatch }}
135
+ onStateChange={vi.fn()}
136
+ />,
137
+ );
138
+ await waitFor(() => {
139
+ expect(container.querySelectorAll('[data-testid="data-table-list-row"]').length).toBe(0);
140
+ expect(screen.getByText("No results found")).toBeTruthy();
141
+ });
142
+ });
143
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * DataTableMobileCards specs — the card body's OWN contract.
3
+ *
4
+ * cardLayout.spec.tsx proves the layout switch, the title slot, label/value
5
+ * rows, column visibility, sorting, search and filtering through the compound
6
+ * table. What no spec pinned is the card's header chrome and its empty state:
7
+ * the select-all bar above the stack, the per-card checkbox / row number /
8
+ * delete cluster beside the title, the delete floor at `minRows`, and the two
9
+ * distinct empty-state copies (no data vs no results) — plus the rule that an
10
+ * in-flight refetch paints NO empty state over a momentarily blank body.
11
+ */
12
+
13
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
14
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
15
+ import { describe, expect, it, vi } from "vitest";
16
+ import type { DataTableColumn, TableFilter } from "../../types";
17
+ import { DataTable } from "./index";
18
+
19
+ type Person = {
20
+ id: number;
21
+ firstName: string;
22
+ lastName: string;
23
+ age: number;
24
+ };
25
+
26
+ const people: Person[] = [
27
+ { id: 1, firstName: "John", lastName: "Doe", age: 28 },
28
+ { id: 2, firstName: "Jane", lastName: "Smith", age: 32 },
29
+ { id: 3, firstName: "Bob", lastName: "Johnson", age: 45 },
30
+ ];
31
+
32
+ const columns: DataTableColumn<Person>[] = [
33
+ { accessorKey: "firstName", header: "First Name" },
34
+ { accessorKey: "lastName", header: "Last Name" },
35
+ { accessorKey: "age", header: "Age" },
36
+ ];
37
+
38
+ function renderCards(props: Record<string, any> = {}) {
39
+ return renderWithProviders(
40
+ <DataTable data={people} columns={columns} layout="cards" onStateChange={vi.fn()} {...props} />,
41
+ );
42
+ }
43
+
44
+ function cards(container: HTMLElement): HTMLElement[] {
45
+ return Array.from(container.querySelectorAll('[data-testid="data-table-card"]'));
46
+ }
47
+
48
+ describe("DataTableMobileCards", () => {
49
+ it("renders one card per row inside the card list container", () => {
50
+ const { container } = renderCards();
51
+ expect(container.querySelector('[data-testid="data-table-cards"]')).not.toBeNull();
52
+ expect(cards(container).length).toBe(people.length);
53
+ });
54
+
55
+ it("the first visible column is the card title, the rest are label/value rows", () => {
56
+ const { container } = renderCards();
57
+ const first = cards(container)[0];
58
+ expect(first.querySelector('[data-card-slot="title"]')?.textContent).toBe("John");
59
+ expect(first.textContent).toContain("Last Name");
60
+ expect(first.textContent).toContain("Doe");
61
+ });
62
+
63
+ it("a single visible column gives a title and no field rows", () => {
64
+ const { container } = renderCards({
65
+ columns: [{ accessorKey: "firstName", header: "First Name" }],
66
+ });
67
+ const first = cards(container)[0];
68
+ expect(first.querySelector('[data-card-slot="title"]')?.textContent).toBe("John");
69
+ expect(first.textContent).not.toContain("Last Name");
70
+ });
71
+
72
+ it("selection adds a select-all bar above the stack and a checkbox per card", () => {
73
+ const { container } = renderCards({ enableRowSelection: true });
74
+ expect(screen.getByLabelText("Select all rows")).toBeTruthy();
75
+ expect(screen.getAllByLabelText(/^Select row /).length).toBe(people.length);
76
+ expect(cards(container).length).toBe(people.length);
77
+ });
78
+
79
+ it("without selection there is no select-all bar", () => {
80
+ renderCards();
81
+ expect(screen.queryByLabelText("Select all rows")).toBeNull();
82
+ });
83
+
84
+ it("select-all checks every card's checkbox", async () => {
85
+ renderCards({ enableRowSelection: true });
86
+ fireEvent.click(screen.getByLabelText("Select all rows"));
87
+ await waitFor(() => {
88
+ const boxes = screen.getAllByLabelText(/^Select row /);
89
+ for (const box of boxes) expect(box.getAttribute("aria-checked")).toBe("true");
90
+ });
91
+ });
92
+
93
+ it("row numbers print in the card header, one-based", () => {
94
+ const { container } = renderCards({ showRowNumbers: true });
95
+ expect(cards(container)[0].textContent).toContain("#1");
96
+ expect(cards(container)[2].textContent).toContain("#3");
97
+ });
98
+
99
+ it("delete is offered per card and reports the row removal to the owner", () => {
100
+ const onDataChange = vi.fn();
101
+ renderCards({ showDeleteRow: true, minRows: 1, onDataChange });
102
+ const deletes = screen.getAllByLabelText(/Delete row/i);
103
+ expect(deletes.length).toBe(people.length);
104
+ fireEvent.click(deletes[0]);
105
+ // The parent owns `data`; the card list asks, it does not splice.
106
+ expect(onDataChange).toHaveBeenCalledTimes(1);
107
+ expect(onDataChange.mock.calls[0][0]).toEqual(people.slice(1));
108
+ });
109
+
110
+ it("delete goes inert at the minRows floor rather than emptying the list", () => {
111
+ const onDataChange = vi.fn();
112
+ renderCards({ showDeleteRow: true, minRows: people.length, onDataChange });
113
+ const deletes = screen.getAllByLabelText(/Delete row/i);
114
+ expect(deletes.length).toBe(people.length);
115
+ fireEvent.click(deletes[0]);
116
+ expect(onDataChange).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it("empty with no filters reads 'No data available'", () => {
120
+ renderWithProviders(
121
+ <DataTable data={[]} columns={columns} layout="cards" onStateChange={vi.fn()} />,
122
+ );
123
+ expect(screen.getByText("No data available")).toBeTruthy();
124
+ });
125
+
126
+ it("empty because a filter matched nothing reads 'No results found'", async () => {
127
+ const noMatch: TableFilter[] = [
128
+ {
129
+ id: "flt-first",
130
+ columnId: "firstName",
131
+ value: { operator: "contains", value: "zzzz" },
132
+ label: "First Name",
133
+ },
134
+ ];
135
+ const { container } = renderWithProviders(
136
+ <DataTable
137
+ data={people}
138
+ columns={columns}
139
+ layout="cards"
140
+ enableFiltering
141
+ filterableFields={[{ id: "firstName", label: "First Name", type: "text" }]}
142
+ initialState={{ filters: noMatch }}
143
+ onStateChange={vi.fn()}
144
+ />,
145
+ );
146
+ await waitFor(() => {
147
+ expect(cards(container).length).toBe(0);
148
+ expect(screen.getByText("No results found")).toBeTruthy();
149
+ });
150
+ });
151
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * CellOptionsEditor — the popover/sheet host for option groups too big for a
3
+ * cell (UX-022 / LC-06 OVERSIZED-EDIT).
4
+ *
5
+ * A radio group rendered inline in a narrow column has options the user cannot
6
+ * reach: the cell's overflow guard clips them. So the cell face becomes a
7
+ * compact FieldDisplay trigger showing the current option label, and the full
8
+ * list opens in a forms FloatingPanel — an anchored popover with collision
9
+ * handling on desktop web, a bottom sheet on native and small viewports —
10
+ * seeded at the cell's current value (Axiom 11).
11
+ *
12
+ * Two classes. COMMIT (`dismissOnPick`, the default) closes on a pick,
13
+ * including re-picking the value already selected. COMPOSE (multi-select
14
+ * toggles) stays open until Escape or an outside click, so several options can
15
+ * be composed in one visit.
16
+ */
17
+
18
+ import type { Meta } from "@storybook/react-native-web-vite";
19
+ import { RadioGroup, ToggleGroup } from "@multiplatform.one/forms";
20
+ import { useState } from "react";
21
+ import { Paragraph, View, YStack } from "tamagui";
22
+ import { CellOptionsEditor } from "./CellOptionsEditor";
23
+
24
+ const meta: Meta<typeof CellOptionsEditor> = {
25
+ title: "Table/CellOptionsEditor",
26
+ component: CellOptionsEditor,
27
+ parameters: {
28
+ status: { type: "stable" },
29
+ docs: {
30
+ description: {
31
+ component:
32
+ "Compact cell face plus a FloatingPanel holding the full option list. " +
33
+ "COMMIT groups close on a pick; COMPOSE groups stay open.",
34
+ },
35
+ },
36
+ },
37
+ };
38
+
39
+ export default meta;
40
+
41
+ const priorities = [
42
+ { label: "Urgent", value: "urgent" },
43
+ { label: "High", value: "high" },
44
+ { label: "Medium", value: "medium" },
45
+ { label: "Low", value: "low" },
46
+ ];
47
+
48
+ const labels = [
49
+ { label: "Bug", value: "bug" },
50
+ { label: "Design", value: "design" },
51
+ { label: "Infra", value: "infra" },
52
+ ];
53
+
54
+ /** A narrow frame, so the reason the panel exists is visible: the cell clips. */
55
+ function CellFrame({ children }: { children: React.ReactNode }) {
56
+ return (
57
+ <View width={180} borderWidth={1} borderColor="$color6" borderRadius="$2" overflow="hidden">
58
+ {children}
59
+ </View>
60
+ );
61
+ }
62
+
63
+ function CommitCell() {
64
+ const [value, setValue] = useState("high");
65
+ return (
66
+ <YStack padding="$4" gap="$3" alignItems="flex-start">
67
+ <Paragraph size="$2">COMMIT: picking is the exit. Panel closes on any pick.</Paragraph>
68
+ <CellFrame>
69
+ <CellOptionsEditor field="radiogroup" value={value} label="Priority">
70
+ {(close) => (
71
+ <RadioGroup
72
+ value={value}
73
+ onValueChange={(next: string) => {
74
+ setValue(next);
75
+ close();
76
+ }}
77
+ options={priorities}
78
+ orientation="vertical"
79
+ />
80
+ )}
81
+ </CellOptionsEditor>
82
+ </CellFrame>
83
+ <Paragraph size="$2" testID="committed-value">{`value: ${value}`}</Paragraph>
84
+ </YStack>
85
+ );
86
+ }
87
+
88
+ export const Default = {
89
+ name: "Main",
90
+ render: () => <CommitCell />,
91
+ };
92
+
93
+ function ComposeCell() {
94
+ const [value, setValue] = useState<string[]>(["bug"]);
95
+ return (
96
+ <YStack padding="$4" gap="$3" alignItems="flex-start">
97
+ <Paragraph size="$2">
98
+ COMPOSE: multi-select stays open so several options land in one visit.
99
+ </Paragraph>
100
+ <CellFrame>
101
+ <CellOptionsEditor field="togglegroup" value={value} label="Labels" dismissOnPick={false}>
102
+ {() => (
103
+ <ToggleGroup
104
+ value={value}
105
+ onValueChange={(next: string | string[]) =>
106
+ setValue(Array.isArray(next) ? next : [next])
107
+ }
108
+ options={labels}
109
+ type="multiple"
110
+ orientation="vertical"
111
+ />
112
+ )}
113
+ </CellOptionsEditor>
114
+ </CellFrame>
115
+ <Paragraph size="$2" testID="committed-value">{`value: ${value.join(", ")}`}</Paragraph>
116
+ </YStack>
117
+ );
118
+ }
119
+
120
+ /** COMPOSE class: `dismissOnPick={false}` keeps the panel open across picks. */
121
+ export const Compose = {
122
+ render: () => <ComposeCell />,
123
+ };
124
+
125
+ /**
126
+ * Disabled: the face renders alone at reduced opacity, with no button role and
127
+ * no panel — a disabled cell is not a keyboard stop.
128
+ */
129
+ export const Disabled = {
130
+ render: () => (
131
+ <YStack padding="$4" gap="$3" alignItems="flex-start">
132
+ <CellFrame>
133
+ <CellOptionsEditor field="radiogroup" value="medium" label="Priority" disabled>
134
+ {() => <RadioGroup value="medium" options={priorities} orientation="vertical" />}
135
+ </CellOptionsEditor>
136
+ </CellFrame>
137
+ </YStack>
138
+ ),
139
+ };
@@ -0,0 +1,127 @@
1
+ /**
2
+ * CellOptionsEditor specs — the popover host's OWN contract (UX-022 / LC-06).
3
+ *
4
+ * dataTableCell.spec.tsx drives this host through DataTableCell with a faked
5
+ * FloatingPanel, so it proves the CELL wires it up. Nothing pinned the host
6
+ * itself: that a disabled editor collapses to a bare face with no trigger and
7
+ * no keyboard stop, that the face is chromeless on focus because the ring
8
+ * belongs to the parent cell (LC-71), that the panel body leaves table-cell
9
+ * context so option groups get their full chrome back, and that `close` is
10
+ * handed to the children so a COMMIT-class pick can dismiss.
11
+ *
12
+ * The panel is faked the same way the sibling cell spec fakes it: content only
13
+ * while open, trigger toggles.
14
+ */
15
+
16
+ import React from "react";
17
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
18
+ import { fireEvent, screen } from "@testing-library/react";
19
+ import { describe, expect, it, vi } from "vitest";
20
+
21
+ vi.mock("@multiplatform.one/forms", async (importOriginal) => ({
22
+ ...(await importOriginal<typeof import("@multiplatform.one/forms")>()),
23
+ FieldDisplay: (props: { field?: string; value?: unknown }) =>
24
+ React.createElement(
25
+ "div",
26
+ { "data-testid": "mock-FieldDisplay", "data-field": props.field },
27
+ String(props.value ?? ""),
28
+ ),
29
+ FloatingPanel: ({ open, onOpenChange, trigger, children }: any) =>
30
+ React.createElement(
31
+ "div",
32
+ { "data-testid": "mock-FloatingPanel" },
33
+ React.createElement(
34
+ "div",
35
+ { "data-testid": "floating-panel-trigger", onClick: () => onOpenChange?.(!open) },
36
+ trigger,
37
+ ),
38
+ open
39
+ ? React.createElement("div", { "data-testid": "floating-panel-content" }, children)
40
+ : null,
41
+ ),
42
+ }));
43
+
44
+ vi.mock("@multiplatform.one/table-primitives", async (importOriginal) => {
45
+ const actual = await importOriginal<typeof import("@multiplatform.one/table-primitives")>();
46
+ return {
47
+ ...actual,
48
+ TableCellContext: {
49
+ ...actual.TableCellContext,
50
+ Provider: ({ value, children }: any) =>
51
+ React.createElement(
52
+ "div",
53
+ { "data-testid": "cell-context", "data-in-table-cell": String(value?.inTableCell) },
54
+ children,
55
+ ),
56
+ },
57
+ };
58
+ });
59
+
60
+ import { CellOptionsEditor } from "./CellOptionsEditor";
61
+
62
+ function renderEditor(props: Record<string, any> = {}, body?: (close: () => void) => any) {
63
+ return renderWithProviders(
64
+ <CellOptionsEditor field="radiogroup" value="high" label="Priority" {...props}>
65
+ {body ??
66
+ ((close) => (
67
+ <button type="button" data-testid="pick" onClick={() => close()}>
68
+ pick
69
+ </button>
70
+ ))}
71
+ </CellOptionsEditor>,
72
+ );
73
+ }
74
+
75
+ describe("CellOptionsEditor", () => {
76
+ it("renders the current value as a compact face, panel closed", () => {
77
+ renderEditor();
78
+ expect(screen.getByTestId("mock-FieldDisplay").textContent).toBe("high");
79
+ expect(screen.queryByTestId("floating-panel-content")).toBeNull();
80
+ });
81
+
82
+ it("the face is a named, focusable button that reports its expanded state", () => {
83
+ const { container } = renderEditor();
84
+ const face = container.querySelector('[role="button"]') as HTMLElement;
85
+ expect(face).not.toBeNull();
86
+ expect(face.getAttribute("aria-label")).toBe("Priority");
87
+ expect(face.getAttribute("aria-expanded")).toBe("false");
88
+ expect(face.getAttribute("tabindex")).toBe("0");
89
+ });
90
+
91
+ it("opening the panel flips aria-expanded and mounts the option group", () => {
92
+ const { container } = renderEditor();
93
+ fireEvent.click(screen.getByTestId("floating-panel-trigger"));
94
+ expect(screen.getByTestId("floating-panel-content")).toBeTruthy();
95
+ expect(
96
+ (container.querySelector('[role="button"]') as HTMLElement).getAttribute("aria-expanded"),
97
+ ).toBe("true");
98
+ });
99
+
100
+ it("hands `close` to the children so a COMMIT pick can dismiss the panel", () => {
101
+ renderEditor();
102
+ fireEvent.click(screen.getByTestId("floating-panel-trigger"));
103
+ fireEvent.click(screen.getByTestId("pick"));
104
+ expect(screen.queryByTestId("floating-panel-content")).toBeNull();
105
+ });
106
+
107
+ it("the panel body leaves table-cell context so options get full chrome back", () => {
108
+ renderEditor();
109
+ fireEvent.click(screen.getByTestId("floating-panel-trigger"));
110
+ const ctx = screen.getByTestId("cell-context");
111
+ expect(ctx.getAttribute("data-in-table-cell")).toBe("false");
112
+ });
113
+
114
+ it("disabled collapses to a bare face: no panel host, no button role, no tab stop", () => {
115
+ const { container } = renderEditor({ disabled: true });
116
+ expect(screen.getByTestId("mock-FieldDisplay").textContent).toBe("high");
117
+ expect(screen.queryByTestId("mock-FloatingPanel")).toBeNull();
118
+ expect(container.querySelector('[role="button"]')).toBeNull();
119
+ expect(container.querySelector("[tabindex]")).toBeNull();
120
+ });
121
+
122
+ it("the face paints no ring of its own — the parent cell owns the one ring (LC-71)", () => {
123
+ const { container } = renderEditor();
124
+ const face = container.querySelector('[role="button"]') as HTMLElement;
125
+ expect(face.style.outlineWidth === "" || face.style.outlineWidth === "0px").toBe(true);
126
+ });
127
+ });
@@ -55,12 +55,17 @@ const mockColumns = [
55
55
  const mockCreateObjectURL = vi.fn(() => "mock-url");
56
56
  const mockRevokeObjectURL = vi.fn();
57
57
 
58
+ // configurable: true is load-bearing — the "Blob URLs are unavailable" case
59
+ // below redefines createObjectURL, and defineProperty defaults configurable to
60
+ // false, which made that redefine throw "Cannot redefine property".
58
61
  Object.defineProperty(window.URL, "createObjectURL", {
62
+ configurable: true,
59
63
  writable: true,
60
64
  value: mockCreateObjectURL,
61
65
  });
62
66
 
63
67
  Object.defineProperty(window.URL, "revokeObjectURL", {
68
+ configurable: true,
64
69
  writable: true,
65
70
  value: mockRevokeObjectURL,
66
71
  });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * FilterChip unit specs — the pill on its own, no DataTable around it.
3
+ *
4
+ * DataTableFilterChips.spec.tsx drives the chip through the compound table and
5
+ * proves the bar's behaviour. This file pins the chip's OWN contract, which the
6
+ * bar cannot state: the `onPress`/no-`onPress` split (an editable chip is a
7
+ * button, a display-only chip is not), that dismiss does not also fire the edit
8
+ * press, and that Enter/Space activate both focusables.
9
+ */
10
+
11
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
12
+ import { fireEvent, screen } from "@testing-library/react";
13
+ import { describe, expect, it, vi } from "vitest";
14
+ import { FilterChip } from "./FilterChip";
15
+
16
+ describe("FilterChip", () => {
17
+ it("renders the label and a dismiss button", () => {
18
+ renderWithProviders(<FilterChip label="Email: contains corp" onRemove={vi.fn()} />);
19
+ expect(screen.getByText("Email: contains corp")).toBeTruthy();
20
+ expect(screen.getByRole("button", { name: "Remove" })).toBeTruthy();
21
+ });
22
+
23
+ it("with onPress the pill is an edit button; both focusables are tab stops (LC-71)", () => {
24
+ renderWithProviders(
25
+ <FilterChip label="Email: contains corp" onPress={vi.fn()} onRemove={vi.fn()} />,
26
+ );
27
+ const edit = screen.getByRole("button", { name: "Edit filter Email: contains corp" });
28
+ const remove = screen.getByRole("button", { name: "Remove" });
29
+ expect(edit.getAttribute("tabindex")).toBe("0");
30
+ expect(remove.getAttribute("tabindex")).toBe("0");
31
+ expect(remove.className).toMatch(/mp-chip-dismiss/);
32
+ });
33
+
34
+ it("without onPress the pill is display-only: no edit button, dismiss still works", () => {
35
+ const onRemove = vi.fn();
36
+ renderWithProviders(<FilterChip label="Department: is Engineering" onRemove={onRemove} />);
37
+ expect(screen.queryByRole("button", { name: /Edit filter/ })).toBeNull();
38
+ fireEvent.click(screen.getByRole("button", { name: "Remove" }));
39
+ expect(onRemove).toHaveBeenCalledTimes(1);
40
+ });
41
+
42
+ it("pressing the pill edits and does not remove", () => {
43
+ const onPress = vi.fn();
44
+ const onRemove = vi.fn();
45
+ renderWithProviders(<FilterChip label="Age: gt 30" onPress={onPress} onRemove={onRemove} />);
46
+ fireEvent.click(screen.getByRole("button", { name: "Edit filter Age: gt 30" }));
47
+ expect(onPress).toHaveBeenCalledTimes(1);
48
+ expect(onRemove).not.toHaveBeenCalled();
49
+ });
50
+
51
+ it("pressing dismiss removes without also firing the chip's edit press", () => {
52
+ const onPress = vi.fn();
53
+ const onRemove = vi.fn();
54
+ renderWithProviders(<FilterChip label="Age: gt 30" onPress={onPress} onRemove={onRemove} />);
55
+ fireEvent.click(screen.getByRole("button", { name: "Remove" }));
56
+ expect(onRemove).toHaveBeenCalledTimes(1);
57
+ expect(onPress).not.toHaveBeenCalled();
58
+ });
59
+
60
+ it("Enter and Space activate the edit press", () => {
61
+ const onPress = vi.fn();
62
+ renderWithProviders(<FilterChip label="Age: gt 30" onPress={onPress} onRemove={vi.fn()} />);
63
+ const edit = screen.getByRole("button", { name: "Edit filter Age: gt 30" });
64
+ fireEvent.keyDown(edit, { key: "Enter" });
65
+ fireEvent.keyDown(edit, { key: " " });
66
+ expect(onPress).toHaveBeenCalledTimes(2);
67
+ });
68
+
69
+ it("Enter on the dismiss glyph removes and does not bubble into the edit press", () => {
70
+ const onPress = vi.fn();
71
+ const onRemove = vi.fn();
72
+ renderWithProviders(<FilterChip label="Age: gt 30" onPress={onPress} onRemove={onRemove} />);
73
+ fireEvent.keyDown(screen.getByRole("button", { name: "Remove" }), { key: "Enter" });
74
+ expect(onRemove).toHaveBeenCalledTimes(1);
75
+ expect(onPress).not.toHaveBeenCalled();
76
+ });
77
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * FilterChip — one active filter as a pill, standalone.
3
+ *
4
+ * DataTableFilterChips stories show the bar; this file shows the unit the bar
5
+ * is made of, with no table around it. Two focusables, two rings (LC-71):
6
+ * pressing the pill reopens the editor and rings the pill, pressing the X
7
+ * removes the filter and rings the glyph, never the 44px hit box. A chip with
8
+ * no `onPress` is display-only — no button role, no pill ring, dismiss still
9
+ * works.
10
+ */
11
+
12
+ import { action } from "@multiplatform.one/storybook";
13
+ import type { Meta } from "@storybook/react-native-web-vite";
14
+ import { useState } from "react";
15
+ import { Paragraph, XStack, YStack } from "tamagui";
16
+ import { FilterChip } from "./FilterChip";
17
+
18
+ const meta: Meta<typeof FilterChip> = {
19
+ title: "Table/FilterChip",
20
+ component: FilterChip,
21
+ parameters: {
22
+ status: { type: "stable" },
23
+ docs: {
24
+ description: {
25
+ component:
26
+ "One active filter as a removable pill. Gray fill, label, dismiss glyph. " +
27
+ "Editable chips take a button role and a focus ring; display-only chips take neither.",
28
+ },
29
+ },
30
+ },
31
+ };
32
+
33
+ export default meta;
34
+
35
+ /** Editable: the pill is a button (edit) and the X is a second button (remove). */
36
+ export const Default = {
37
+ name: "Main",
38
+ render: () => (
39
+ <YStack padding="$4" gap="$3" alignItems="flex-start">
40
+ <FilterChip
41
+ label="Email contains corp"
42
+ onPress={action("press")}
43
+ onRemove={action("remove")}
44
+ />
45
+ </YStack>
46
+ ),
47
+ };
48
+
49
+ /** Without `onPress` the chip is display-only: no button role on the pill. */
50
+ export const DisplayOnly = {
51
+ render: () => (
52
+ <YStack padding="$4" gap="$3" alignItems="flex-start">
53
+ <FilterChip label="Department is Engineering" onRemove={action("remove")} />
54
+ </YStack>
55
+ ),
56
+ };
57
+
58
+ /** A row of siblings, as the chips bar renders them. Long labels do not truncate. */
59
+ export const Row = {
60
+ render: () => (
61
+ <YStack padding="$4" gap="$3" alignItems="flex-start">
62
+ <XStack gap="$2" flexWrap="wrap">
63
+ <FilterChip
64
+ label="Email contains corp"
65
+ onPress={action("press")}
66
+ onRemove={action("remove")}
67
+ />
68
+ <FilterChip
69
+ label="Department is Engineering"
70
+ onPress={action("press")}
71
+ onRemove={action("remove")}
72
+ />
73
+ <FilterChip
74
+ label="Age is greater than 30"
75
+ onPress={action("press")}
76
+ onRemove={action("remove")}
77
+ />
78
+ </XStack>
79
+ </YStack>
80
+ ),
81
+ };
82
+
83
+ function RemovableRow() {
84
+ const [labels, setLabels] = useState([
85
+ "Email contains corp",
86
+ "Department is Engineering",
87
+ "Age is greater than 30",
88
+ ]);
89
+ return (
90
+ <YStack padding="$4" gap="$3" alignItems="flex-start">
91
+ <XStack gap="$2" flexWrap="wrap">
92
+ {labels.map((label) => (
93
+ <FilterChip
94
+ key={label}
95
+ label={label}
96
+ onPress={action("press")}
97
+ onRemove={() => setLabels((current) => current.filter((item) => item !== label))}
98
+ />
99
+ ))}
100
+ </XStack>
101
+ <Paragraph size="$2" testID="chip-count">
102
+ {`${labels.length} active`}
103
+ </Paragraph>
104
+ </YStack>
105
+ );
106
+ }
107
+
108
+ /** Dismiss actually removes: press an X and the count drops. */
109
+ export const Removable = {
110
+ render: () => <RemovableRow />,
111
+ };