@payglocal_ui/flux-ui 0.2.6 → 0.3.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,7 +1,7 @@
1
1
  {
2
2
  "name": "@payglocal_ui/flux-ui",
3
- "version": "0.2.6",
4
- "description": "Flux UI primitives inputs, fields, dialog, data table, charts, calendar, and more (Tailwind v4 + Radix).",
3
+ "version": "0.3.0",
4
+ "description": "Flux UI primitives \u2014 inputs, fields, dialog, data table, charts, calendar, and more (Tailwind v4 + Radix).",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "sideEffects": false,
@@ -37,6 +37,9 @@
37
37
  "react-dom": "^18.0.0 || ^19.0.0"
38
38
  },
39
39
  "dependencies": {
40
+ "@dnd-kit/core": "^6.3.1",
41
+ "@dnd-kit/sortable": "^10.0.0",
42
+ "@dnd-kit/utilities": "^3.2.2",
40
43
  "@radix-ui/react-accordion": "^1.2.13",
41
44
  "@radix-ui/react-avatar": "^1.1.11",
42
45
  "@radix-ui/react-checkbox": "^1.3.4",
@@ -65,14 +68,21 @@
65
68
  "tailwind-merge": "^3.5.0"
66
69
  },
67
70
  "devDependencies": {
71
+ "@testing-library/react": "^16.3.3",
72
+ "@testing-library/user-event": "^14.6.7",
68
73
  "@types/react": "^19",
69
74
  "@types/react-dom": "^19",
75
+ "@vitejs/plugin-react": "^6.1.1",
70
76
  "esbuild": "^0.28.1",
77
+ "jsdom": "^29.1.1",
71
78
  "tsup": "^8.5.1",
72
- "typescript": "^5"
79
+ "typescript": "^5",
80
+ "vitest": "^5.0.1"
73
81
  },
74
82
  "scripts": {
75
83
  "build": "tsup",
76
- "check-types": "tsc --noEmit"
84
+ "check-types": "tsc --noEmit",
85
+ "test": "vitest run",
86
+ "test:watch": "vitest"
77
87
  }
78
88
  }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, screen, fireEvent } from "@testing-library/react";
3
+ import { DataCardList } from "../data-card-list";
4
+
5
+ const rows = Array.from({ length: 25 }, (_, i) => ({ id: `r${i}`, name: `Row ${i}` }));
6
+ const card = (r: { name: string }) => <span>{r.name}</span>;
7
+
8
+ describe("DataCardList", () => {
9
+ it("slices locally in client mode and pages through", () => {
10
+ render(
11
+ <DataCardList
12
+ rows={rows}
13
+ rowKey={(r) => r.id}
14
+ renderCard={card}
15
+ pagination={{ mode: "client", pageSize: 10 }}
16
+ />
17
+ );
18
+ expect(screen.getAllByText(/^Row /).length).toBe(10);
19
+ expect(screen.getByText(/Page 1 of 3/)).toBeTruthy();
20
+ });
21
+
22
+ it("does NOT slice in page mode — the caller sent this page", () => {
23
+ const onPageChange = vi.fn();
24
+ render(
25
+ <DataCardList
26
+ rows={rows.slice(0, 10)}
27
+ rowKey={(r) => r.id}
28
+ renderCard={card}
29
+ pagination={{ mode: "page", page: 2, pageSize: 10, total: 25, onPageChange }}
30
+ />
31
+ );
32
+ expect(screen.getAllByText(/^Row /).length).toBe(10);
33
+ expect(screen.getByText(/Page 2 of 3/)).toBeTruthy();
34
+ fireEvent.click(screen.getByText("Next"));
35
+ expect(onPageChange).toHaveBeenCalledWith(3);
36
+ });
37
+
38
+ it("shows no total in cursor mode, and hides Next at the end", () => {
39
+ const onNext = vi.fn();
40
+ const { rerender } = render(
41
+ <DataCardList
42
+ rows={rows.slice(0, 5)}
43
+ rowKey={(r) => r.id}
44
+ renderCard={card}
45
+ pagination={{ mode: "cursor", page: 2, pageSize: 5, hasNext: true, onNext, onPrev: () => {} }}
46
+ />
47
+ );
48
+ expect(screen.getByText("Page 2")).toBeTruthy();
49
+ expect(screen.queryByText(/of/)).toBeNull();
50
+ fireEvent.click(screen.getByText("Next"));
51
+ expect(onNext).toHaveBeenCalled();
52
+
53
+ rerender(
54
+ <DataCardList
55
+ rows={rows.slice(0, 5)}
56
+ rowKey={(r) => r.id}
57
+ renderCard={card}
58
+ pagination={{ mode: "cursor", page: 2, pageSize: 5, hasNext: false, onNext, onPrev: () => {} }}
59
+ />
60
+ );
61
+ expect(screen.getByText("Next").closest("button")?.disabled).toBe(true);
62
+ });
63
+
64
+ it("shows skeletons while loading and an empty state with no rows", () => {
65
+ const { rerender, container } = render(
66
+ <DataCardList rows={[]} rowKey={(r: any) => r.id} renderCard={card} isLoading skeletonCount={4} />
67
+ );
68
+ // Four skeleton cards, each built from several Shimmer elements.
69
+ expect(container.querySelectorAll(".shimmer").length).toBeGreaterThanOrEqual(4);
70
+
71
+ rerender(<DataCardList rows={[]} rowKey={(r: any) => r.id} renderCard={card} emptyTitle="Nothing here" />);
72
+ expect(screen.getByText("Nothing here")).toBeTruthy();
73
+ });
74
+ });
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it, beforeAll, vi } from "vitest";
2
+ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
3
+ import { DataTable } from "../data-table";
4
+ import { Popover, PopoverContent, PopoverTrigger } from "../popover";
5
+
6
+ beforeAll(() => {
7
+ if (!(globalThis as any).PointerEvent) {
8
+ class PE extends MouseEvent {
9
+ pointerType: string;
10
+ constructor(type: string, props: any = {}) {
11
+ super(type, props);
12
+ this.pointerType = props.pointerType ?? "mouse";
13
+ }
14
+ }
15
+ (globalThis as any).PointerEvent = PE;
16
+ }
17
+ if (!(globalThis as any).ResizeObserver) {
18
+ (globalThis as any).ResizeObserver = class {
19
+ observe() {}
20
+ unobserve() {}
21
+ disconnect() {}
22
+ };
23
+ }
24
+ });
25
+
26
+ type Row = { id: string; name: string };
27
+ const ROWS: Row[] = [{ id: "a", name: "Widget" }];
28
+ const COLUMNS = [{ key: "name", header: "Name", render: (r: Row) => <span>{r.name}</span> }];
29
+
30
+ /** Mirrors SkuRowActions: a portalled popover menu in the row-action slot. */
31
+ function RowMenu({ onEdit }: { onEdit: () => void }) {
32
+ return (
33
+ <Popover>
34
+ <PopoverTrigger asChild>
35
+ <button type="button" aria-label="Actions">
36
+ ...
37
+ </button>
38
+ </PopoverTrigger>
39
+ <PopoverContent>
40
+ <button type="button" onClick={onEdit}>
41
+ Edit item
42
+ </button>
43
+ </PopoverContent>
44
+ </Popover>
45
+ );
46
+ }
47
+
48
+ describe("onRowClick", () => {
49
+ it("does not fire when a portalled row menu item is chosen", async () => {
50
+ const onRowClick = vi.fn();
51
+ const onEdit = vi.fn();
52
+ render(
53
+ <DataTable<Row>
54
+ columns={COLUMNS}
55
+ data={ROWS}
56
+ rowKey={(r) => r.id}
57
+ onRowClick={onRowClick}
58
+ rowAction={() => <RowMenu onEdit={onEdit} />}
59
+ />
60
+ );
61
+
62
+ fireEvent.click(screen.getByRole("button", { name: "Actions" }));
63
+ await waitFor(() => expect(screen.queryByText("Edit item")).toBeTruthy());
64
+ fireEvent.click(screen.getByText("Edit item"));
65
+
66
+ expect(onEdit).toHaveBeenCalledTimes(1);
67
+ // The menu is portalled to document.body, so React routes its click through
68
+ // the row. Before the fix this opened the row's own view as well.
69
+ expect(onRowClick).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it("still fires for a click on the row body", () => {
73
+ const onRowClick = vi.fn();
74
+ render(
75
+ <DataTable<Row>
76
+ columns={COLUMNS}
77
+ data={ROWS}
78
+ rowKey={(r) => r.id}
79
+ onRowClick={onRowClick}
80
+ />
81
+ );
82
+
83
+ fireEvent.click(screen.getByText("Widget"));
84
+ expect(onRowClick).toHaveBeenCalledTimes(1);
85
+ });
86
+
87
+ it("does not fire for a control inside a cell", () => {
88
+ const onRowClick = vi.fn();
89
+ const onInner = vi.fn();
90
+ render(
91
+ <DataTable<Row>
92
+ columns={[
93
+ {
94
+ key: "name",
95
+ header: "Name",
96
+ render: (r: Row) => (
97
+ <button type="button" onClick={onInner}>
98
+ {r.name}
99
+ </button>
100
+ ),
101
+ },
102
+ ]}
103
+ data={ROWS}
104
+ rowKey={(r) => r.id}
105
+ onRowClick={onRowClick}
106
+ />
107
+ );
108
+
109
+ fireEvent.click(screen.getByText("Widget"));
110
+ expect(onInner).toHaveBeenCalledTimes(1);
111
+ expect(onRowClick).not.toHaveBeenCalled();
112
+ });
113
+ });
@@ -0,0 +1,237 @@
1
+ import { describe, expect, it, beforeAll } from "vitest";
2
+ import { render, screen, waitFor, fireEvent } from "@testing-library/react";
3
+ import { useState } from "react";
4
+ import {
5
+ DateRangeFilterChip,
6
+ FilterToolbar,
7
+ SelectFilterChip,
8
+ AddFilterMenu,
9
+ FilterChipGroup,
10
+ useFilterChipState,
11
+ } from "../filter-chips";
12
+
13
+ beforeAll(() => {
14
+ if (!(globalThis as any).PointerEvent) {
15
+ class PE extends MouseEvent {
16
+ pointerType: string;
17
+ constructor(type: string, props: any = {}) {
18
+ super(type, props);
19
+ this.pointerType = props.pointerType ?? "mouse";
20
+ }
21
+ }
22
+ (globalThis as any).PointerEvent = PE;
23
+ }
24
+ // Radix measures the trigger to position the popover.
25
+ if (!(globalThis as any).ResizeObserver) {
26
+ (globalThis as any).ResizeObserver = class {
27
+ observe() {}
28
+ unobserve() {}
29
+ disconnect() {}
30
+ };
31
+ }
32
+ });
33
+
34
+ // The real toolbar has ~10 options per category, which crosses
35
+ // SelectFilterChip's searchThreshold and renders a search box in the panel.
36
+ const MANY = Array.from({ length: 10 }, (_, i) => ({
37
+ value: `v${i}`,
38
+ label: `Option ${i}`,
39
+ }));
40
+
41
+ /** Mirrors TxnFilters: search, Date chip, two category chips, an add menu. */
42
+ function Toolbar() {
43
+ const [date, setDate] = useState({ from: "", to: "" });
44
+ const [type, setType] = useState<string[]>([]);
45
+ const [status, setStatus] = useState<string[]>([]);
46
+ return (
47
+ <FilterToolbar
48
+ search={<input aria-label="Search" />}
49
+ chips={
50
+ <>
51
+ <DateRangeFilterChip value={date} onChange={setDate} />
52
+ <SelectFilterChip label="Transaction Type" options={MANY} selected={type} onChange={setType} />
53
+ <SelectFilterChip label="Transaction Status" options={MANY} selected={status} onChange={setStatus} />
54
+ <AddFilterMenu filters={[{ key: "x", label: "Country", options: MANY }]} onAddFilter={() => {}} />
55
+ </>
56
+ }
57
+ />
58
+ );
59
+ }
60
+
61
+ function press(el: Element) {
62
+ fireEvent.pointerDown(el, { pointerType: "mouse", button: 0, bubbles: true });
63
+ fireEvent.mouseDown(el, { button: 0, bubbles: true });
64
+ fireEvent.pointerUp(el, { pointerType: "mouse", button: 0, bubbles: true });
65
+ fireEvent.mouseUp(el, { button: 0, bubbles: true });
66
+ fireEvent.click(el, { button: 0, bubbles: true });
67
+ }
68
+
69
+ const which = () =>
70
+ screen.queryAllByPlaceholderText(/^Search /).map((i) => i.getAttribute("placeholder"));
71
+
72
+ describe("switching straight between two open chips", () => {
73
+ it("closes the first and leaves the second open", async () => {
74
+ render(<Toolbar />);
75
+
76
+ press(screen.getByRole("button", { name: /Transaction Type/ }));
77
+ await waitFor(() => expect(which()).toContain("Search transaction type"));
78
+ console.log("after opening Type :", which());
79
+
80
+ press(screen.getByRole("button", { name: /Transaction Status/ }));
81
+ await new Promise((r) => setTimeout(r, 300));
82
+ console.log("after clicking Status:", which());
83
+
84
+ expect(which()).toEqual(["Search transaction status"]);
85
+ });
86
+ });
87
+
88
+ describe("the handoff", () => {
89
+ it("never has two chip popovers open at once", async () => {
90
+ render(<Toolbar />);
91
+
92
+ press(screen.getByRole("button", { name: /Transaction Type/ }));
93
+ await waitFor(() => expect(which()).toContain("Search transaction type"));
94
+
95
+ press(screen.getByRole("button", { name: /Transaction Status/ }));
96
+
97
+ // Sample across the handoff frame; at no point should both be mounted.
98
+ for (let i = 0; i < 20; i++) {
99
+ expect(which().length).toBeLessThanOrEqual(1);
100
+ await new Promise((r) => setTimeout(r, 20));
101
+ }
102
+ expect(which()).toEqual(["Search transaction status"]);
103
+ });
104
+
105
+ it("still closes on a second click of the same chip", async () => {
106
+ render(<Toolbar />);
107
+ const type = screen.getByRole("button", { name: /Transaction Type/ });
108
+
109
+ press(type);
110
+ await waitFor(() => expect(which().length).toBe(1));
111
+ press(type);
112
+ await waitFor(() => expect(which().length).toBe(0));
113
+ });
114
+ });
115
+
116
+ /**
117
+ * The regression this file exists for.
118
+ *
119
+ * Radix restores focus to a popover's trigger on close, skipping it only when
120
+ * the popover was dismissed by an *outside interaction*. A handoff is neither:
121
+ * the group closes the outgoing chip programmatically, so Radix restores focus
122
+ * to its trigger — which lands outside the chip now opening and makes Radix
123
+ * dismiss that one. The chip appears and vanishes.
124
+ */
125
+ describe("handoff focus suppression", () => {
126
+ it("suppresses the outgoing chip's focus restore exactly once", async () => {
127
+ const seen: Array<{ key: string; prevented: boolean }> = [];
128
+
129
+ function Probe() {
130
+ const a = useFilterChipState("a");
131
+ const b = useFilterChipState("b");
132
+ return (
133
+ <>
134
+ <button onClick={() => a.onOpenChange(true)}>open a</button>
135
+ <button onClick={() => b.onOpenChange(true)}>open b</button>
136
+ <button
137
+ onClick={() => {
138
+ for (const [key, chip] of [
139
+ ["a", a],
140
+ ["b", b],
141
+ ] as const) {
142
+ const e = new Event("x", { cancelable: true });
143
+ chip.onCloseAutoFocus(e);
144
+ seen.push({ key, prevented: e.defaultPrevented });
145
+ }
146
+ }}
147
+ >
148
+ close-auto-focus
149
+ </button>
150
+ </>
151
+ );
152
+ }
153
+
154
+ render(
155
+ <FilterChipGroup>
156
+ <Probe />
157
+ </FilterChipGroup>
158
+ );
159
+
160
+ fireEvent.click(screen.getByText("open a"));
161
+ fireEvent.click(screen.getByText("open b")); // handoff: a -> b
162
+ fireEvent.click(screen.getByText("close-auto-focus"));
163
+
164
+ // Only the chip handed off from suppresses its focus restore.
165
+ expect(seen).toEqual([
166
+ { key: "a", prevented: true },
167
+ { key: "b", prevented: false },
168
+ ]);
169
+
170
+ // And only once — a later close must restore focus normally.
171
+ seen.length = 0;
172
+ fireEvent.click(screen.getByText("close-auto-focus"));
173
+ expect(seen).toEqual([
174
+ { key: "a", prevented: false },
175
+ { key: "b", prevented: false },
176
+ ]);
177
+ });
178
+ });
179
+
180
+ /**
181
+ * Both footer buttons commit and close. Clear used to reset only the draft and
182
+ * leave the panel open, so the chip still read "Type 1" while the list in front
183
+ * of you showed nothing ticked — and closing the panel kept the old filter.
184
+ */
185
+ describe("the Apply / Clear footer", () => {
186
+ function ApplyClearToolbar() {
187
+ const [type, setType] = useState<string[]>([]);
188
+ return (
189
+ <FilterToolbar
190
+ chips={<SelectFilterChip label="Type" options={MANY} selected={type} onChange={setType} />}
191
+ />
192
+ );
193
+ }
194
+
195
+ const openTypeChip = async () => {
196
+ fireEvent.click(screen.getByRole("button", { name: /^Type/ }));
197
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeTruthy());
198
+ };
199
+
200
+ it("Apply commits the draft and closes", async () => {
201
+ render(<ApplyClearToolbar />);
202
+ await openTypeChip();
203
+ fireEvent.click(screen.getByText("Option 1"));
204
+ fireEvent.click(screen.getByRole("button", { name: "Apply" }));
205
+
206
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeFalsy());
207
+ expect(screen.getByRole("button", { name: /^Type/ }).textContent).toContain("1");
208
+ });
209
+
210
+ it("Clear drops the applied filter and closes", async () => {
211
+ render(<ApplyClearToolbar />);
212
+ await openTypeChip();
213
+ fireEvent.click(screen.getByText("Option 1"));
214
+ fireEvent.click(screen.getByRole("button", { name: "Apply" }));
215
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeFalsy());
216
+
217
+ await openTypeChip();
218
+ fireEvent.click(screen.getByRole("button", { name: "Clear" }));
219
+
220
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeFalsy());
221
+ expect(screen.getByRole("button", { name: /^Type/ }).textContent).not.toContain("1");
222
+ });
223
+
224
+ it("Clear stays live over an applied filter whose draft has been emptied", async () => {
225
+ render(<ApplyClearToolbar />);
226
+ await openTypeChip();
227
+ fireEvent.click(screen.getByText("Option 1"));
228
+ fireEvent.click(screen.getByRole("button", { name: "Apply" }));
229
+ await waitFor(() => expect(screen.queryByRole("dialog")).toBeFalsy());
230
+
231
+ await openTypeChip();
232
+ // Untick it again: the draft is empty but there is still a filter applied.
233
+ fireEvent.click(screen.getByText("Option 1"));
234
+ const clear = screen.getByRole("button", { name: "Clear" }) as HTMLButtonElement;
235
+ expect(clear.disabled).toBe(false);
236
+ });
237
+ });