@multiplatform.one/table 7.6.3 → 7.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -13
- package/src/components/ChildTable/ChildTable.stories.tsx +4 -1
- package/src/components/DataTable/DataTable.spec.tsx +13 -7
- package/src/components/DataTable/DataTableCardControls.stories.tsx +116 -0
- package/src/components/DataTable/DataTableDocumentList.stories.tsx +132 -0
- package/src/components/DataTable/DataTableMobileCards.stories.tsx +124 -0
- package/src/components/DataTable/DataTableVirtualized.stories.tsx +69 -0
- package/src/components/DataTable/chromeInterplay.spec.tsx +4 -1
- package/src/components/DataTable/dataTableCardControls.spec.tsx +99 -0
- package/src/components/DataTable/dataTableDocumentList.spec.tsx +143 -0
- package/src/components/DataTable/dataTableMobileCards.spec.tsx +151 -0
- package/src/components/DataTableCell/CellOptionsEditor.stories.tsx +139 -0
- package/src/components/DataTableCell/cellOptionsEditor.spec.tsx +127 -0
- package/src/components/DataTableExport/DataTableExport.spec.tsx +5 -0
- package/src/components/DataTableFilterButton/DataTableFilterButton.spec.tsx +128 -0
- package/src/components/DataTableFilterButton/DataTableFilterButton.stories.tsx +99 -0
- package/src/components/DataTableFilterChips/DataTableFilterChips.spec.tsx +6 -81
- package/src/components/DataTableFilterChips/DataTableFilterChips.stories.tsx +105 -0
- package/src/components/DataTableFilterChips/FilterChip.spec.tsx +77 -0
- package/src/components/DataTableFilterChips/FilterChip.stories.tsx +111 -0
- package/src/components/FilterManager.stories.tsx +75 -0
- package/src/components/FilterOperatorValueEditor/FilterOperatorValueEditor.spec.tsx +261 -0
- package/src/components/FilterOperatorValueEditor/FilterOperatorValueEditor.stories.tsx +183 -0
- package/src/components/KnobDialog.spec.tsx +88 -0
- package/src/components/KnobDialog.stories.tsx +66 -0
- package/src/components/SavedFilterDropdown.stories.tsx +90 -0
|
@@ -0,0 +1,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
|
});
|