@payglocal_ui/flux-ui 0.2.6 → 0.3.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": "@payglocal_ui/flux-ui",
3
- "version": "0.2.6",
3
+ "version": "0.3.1",
4
4
  "description": "Flux UI primitives — inputs, fields, dialog, data table, charts, calendar, and more (Tailwind v4 + Radix).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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",
@@ -60,19 +63,27 @@
60
63
  "lucide-react": "^0.577.0",
61
64
  "next-themes": "^0.4.6",
62
65
  "react-day-picker": "^9.14.0",
66
+ "react-remove-scroll": "^2.7.2",
63
67
  "recharts": "^3.8.0",
64
68
  "sonner": "^2.0.7",
65
69
  "tailwind-merge": "^3.5.0"
66
70
  },
67
71
  "devDependencies": {
72
+ "@testing-library/react": "^16.3.3",
73
+ "@testing-library/user-event": "^14.6.7",
68
74
  "@types/react": "^19",
69
75
  "@types/react-dom": "^19",
76
+ "@vitejs/plugin-react": "^6.1.1",
70
77
  "esbuild": "^0.28.1",
78
+ "jsdom": "^29.1.1",
71
79
  "tsup": "^8.5.1",
72
- "typescript": "^5"
80
+ "typescript": "^5",
81
+ "vitest": "^5.0.1"
73
82
  },
74
83
  "scripts": {
75
84
  "build": "tsup",
76
- "check-types": "tsc --noEmit"
85
+ "check-types": "tsc --noEmit",
86
+ "test": "vitest run",
87
+ "test:watch": "vitest"
77
88
  }
78
89
  }
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it, beforeAll, vi } from "vitest";
2
+ import { render, screen, waitFor, fireEvent } from "@testing-library/react";
3
+ import { CopyableCell } from "../copyable-cell";
4
+
5
+ /**
6
+ * The full value belongs to the text that was shortened, not to the copy
7
+ * button. Reading an id used to mean hovering the control that copies it.
8
+ */
9
+
10
+ const FULL = "286234f3-6b18-4445-ba1f-0c814f154b0e";
11
+ const SHORT = "286234f3-6b1…4f154b0e";
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
+ if (!(globalThis as any).ResizeObserver) {
25
+ (globalThis as any).ResizeObserver = class {
26
+ observe() {}
27
+ unobserve() {}
28
+ disconnect() {}
29
+ };
30
+ }
31
+ if (!(globalThis as any).DOMRect) {
32
+ (globalThis as any).DOMRect = class {
33
+ constructor(public x = 0, public y = 0, public width = 0, public height = 0) {}
34
+ };
35
+ }
36
+ });
37
+
38
+ describe("CopyableCell tooltips", () => {
39
+ it("shows the whole value from the elided text, not from the copy button", async () => {
40
+ render(<CopyableCell value={FULL} display={SHORT} label="Payout ID" />);
41
+
42
+ // The copy button says what it does, and no longer carries the value.
43
+ const button = screen.getByRole("button", { name: "Copy Payout ID" });
44
+ fireEvent.pointerEnter(button);
45
+ fireEvent.focus(button);
46
+ await waitFor(() => expect(screen.getAllByText("Copy Payout ID").length).toBeGreaterThan(0));
47
+ expect(screen.queryByText(`Copy ${FULL}`)).toBeNull();
48
+
49
+ // Hovering the text is what reveals the id.
50
+ fireEvent.pointerEnter(screen.getByText(SHORT));
51
+ await waitFor(() => expect(screen.getAllByText(FULL).length).toBeGreaterThan(0));
52
+ });
53
+
54
+ it("does not add a tooltip when nothing was elided", async () => {
55
+ render(<CopyableCell value="SHORTID" label="Batch" />);
56
+
57
+ const text = screen.getByText("SHORTID");
58
+ // The native title still covers a value the column's own truncate may cut.
59
+ expect(text.getAttribute("title")).toBe("SHORTID");
60
+ expect(text.getAttribute("aria-hidden")).toBeNull();
61
+ });
62
+
63
+ it("gives a screen reader the whole value, once", () => {
64
+ const { container } = render(<CopyableCell value={FULL} display={SHORT} />);
65
+
66
+ // The elided form is decorative; the sr-only twin is what gets read.
67
+ expect(screen.getByText(SHORT).getAttribute("aria-hidden")).toBe("true");
68
+ expect(container.querySelector(".sr-only")?.textContent).toBe(FULL);
69
+ });
70
+
71
+ it("names a clickable value with the full id instead of the elided one", () => {
72
+ const onClick = vi.fn();
73
+ render(<CopyableCell value={FULL} display={SHORT} onClick={onClick} />);
74
+
75
+ const link = screen.getByRole("button", { name: FULL });
76
+ fireEvent.click(link);
77
+ expect(onClick).toHaveBeenCalled();
78
+ // No second copy of the value for a control that already names itself.
79
+ expect(link.parentElement?.querySelector(".sr-only")).toBeNull();
80
+ });
81
+ });
@@ -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,189 @@
1
+ import { describe, expect, it, beforeAll, vi } from "vitest";
2
+ import { render, screen, waitFor, fireEvent, within } from "@testing-library/react";
3
+ import { useState } from "react";
4
+ import { DatePicker } from "../date-picker";
5
+
6
+ /**
7
+ * `showTime` follows antd: time columns beside the calendar, a Now/OK footer,
8
+ * and a day click that no longer closes the panel because the day is only half
9
+ * the answer.
10
+ */
11
+
12
+ beforeAll(() => {
13
+ Element.prototype.scrollIntoView = vi.fn();
14
+ Element.prototype.scrollTo = vi.fn();
15
+ });
16
+
17
+ function Harness({
18
+ initial,
19
+ showTime,
20
+ onChange,
21
+ }: {
22
+ initial: string;
23
+ showTime?: boolean | Record<string, unknown>;
24
+ onChange?: (v: string) => void;
25
+ }) {
26
+ const [value, setValue] = useState(initial);
27
+ return (
28
+ <DatePicker
29
+ value={value}
30
+ showTime={showTime as never}
31
+ onChange={(v) => {
32
+ setValue(v);
33
+ onChange?.(v);
34
+ }}
35
+ />
36
+ );
37
+ }
38
+
39
+ /** The scrollable list under a column heading. */
40
+ function column(label: string) {
41
+ return screen.getByText(label).parentElement as HTMLElement;
42
+ }
43
+
44
+ /**
45
+ * A day cell in the calendar grid. Scoped by shape rather than by text, because
46
+ * with the time columns up a label like "17" is also a minute.
47
+ */
48
+ function day(n: number) {
49
+ const cell = screen
50
+ .getAllByRole("button", { name: String(n) })
51
+ .find((b) => b.className.includes("rounded-full"));
52
+ if (!cell) throw new Error(`no day cell ${n}`);
53
+ return cell;
54
+ }
55
+
56
+ describe("DatePicker showTime", () => {
57
+ it("leaves the date-only picker untouched", async () => {
58
+ const onChange = vi.fn();
59
+ render(<Harness initial="2026-09-10" onChange={onChange} />);
60
+
61
+ fireEvent.click(screen.getByText("10 Sep 2026"));
62
+ await screen.findByText("Su");
63
+ fireEvent.click(day(17));
64
+
65
+ // Date-only value, and the panel closes on the day click as it always did.
66
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith("2026-09-17"));
67
+ expect(screen.queryByText("OK")).toBeNull();
68
+ await waitFor(() => expect(screen.queryByText("Hr")).toBeNull());
69
+ });
70
+
71
+ it("widens the value to date and time, and keeps the panel open on a day click", async () => {
72
+ const onChange = vi.fn();
73
+ render(<Harness initial="2026-09-10 14:30" showTime onChange={onChange} />);
74
+
75
+ // Trigger reads back in 12-hour, like every other flux timestamp.
76
+ fireEvent.click(screen.getByText("10 Sep 2026, 02:30 PM"));
77
+
78
+ await screen.findByText("Hr");
79
+ fireEvent.click(day(17));
80
+
81
+ // Time carried across, and the panel is still up so a time can be picked.
82
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith("2026-09-17 14:30"));
83
+ expect(screen.getByText("OK")).toBeTruthy();
84
+ });
85
+
86
+ it("edits hour, minute and meridiem independently", async () => {
87
+ const onChange = vi.fn();
88
+ render(<Harness initial="2026-09-10 14:30" showTime onChange={onChange} />);
89
+ fireEvent.click(screen.getByText("10 Sep 2026, 02:30 PM"));
90
+ await screen.findByText("Hr");
91
+
92
+ // 09 in the 12-hour column, still PM.
93
+ fireEvent.click(within(column("Hr")).getByRole("button", { name: "09" }));
94
+ await waitFor(() => expect(onChange).toHaveBeenLastCalledWith("2026-09-10 21:30"));
95
+
96
+ fireEvent.click(within(column("Min")).getByRole("button", { name: "45" }));
97
+ await waitFor(() => expect(onChange).toHaveBeenLastCalledWith("2026-09-10 21:45"));
98
+
99
+ fireEvent.click(within(column("AM/PM")).getByRole("button", { name: "AM" }));
100
+ await waitFor(() => expect(onChange).toHaveBeenLastCalledWith("2026-09-10 09:45"));
101
+ });
102
+
103
+ it("orders the 12-hour column 12 first, so midnight is the first AM hour", async () => {
104
+ render(<Harness initial="2026-09-10 14:30" showTime />);
105
+ fireEvent.click(screen.getByText("10 Sep 2026, 02:30 PM"));
106
+ await screen.findByText("Hr");
107
+
108
+ const labels = within(column("Hr"))
109
+ .getAllByRole("button")
110
+ .map((b) => b.textContent);
111
+ expect(labels.slice(0, 3)).toEqual(["12", "01", "02"]);
112
+ expect(labels).toHaveLength(12);
113
+
114
+ // 12 AM is midnight, not noon.
115
+ fireEvent.click(within(column("Hr")).getByRole("button", { name: "12" }));
116
+ fireEvent.click(within(column("AM/PM")).getByRole("button", { name: "AM" }));
117
+ await waitFor(() =>
118
+ expect(screen.getByText("10 Sep 2026, 12:30 AM")).toBeTruthy()
119
+ );
120
+ });
121
+
122
+ it("OK closes the panel and Now fills in the current instant", async () => {
123
+ const onChange = vi.fn();
124
+ render(<Harness initial="2026-09-10 14:30" showTime onChange={onChange} />);
125
+ fireEvent.click(screen.getByText("10 Sep 2026, 02:30 PM"));
126
+
127
+ fireEvent.click(await screen.findByText("OK"));
128
+ await waitFor(() => expect(screen.queryByText("Hr")).toBeNull());
129
+
130
+ fireEvent.click(screen.getByText("10 Sep 2026, 02:30 PM"));
131
+ fireEvent.click(await screen.findByText("Now"));
132
+
133
+ await waitFor(() => expect(onChange).toHaveBeenCalled());
134
+ const emitted = onChange.mock.calls.at(-1)![0] as string;
135
+ expect(emitted).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/);
136
+ // Now also dismisses, since it answers both halves at once.
137
+ await waitFor(() => expect(screen.queryByText("Hr")).toBeNull());
138
+ });
139
+
140
+ it("honours showSecond and the step options", async () => {
141
+ const onChange = vi.fn();
142
+ render(
143
+ <Harness
144
+ initial="2026-09-10 14:30:00"
145
+ showTime={{ showSecond: true, minuteStep: 15, use12Hours: false }}
146
+ onChange={onChange}
147
+ />
148
+ );
149
+ fireEvent.click(screen.getByText("10 Sep 2026, 02:30:00 PM"));
150
+ await screen.findByText("Sec");
151
+
152
+ // 24-hour column, so no meridiem beside it.
153
+ expect(screen.queryByText("AM/PM")).toBeNull();
154
+ expect(within(column("Hr")).getAllByRole("button")).toHaveLength(24);
155
+ expect(
156
+ within(column("Min")).getAllByRole("button").map((b) => b.textContent)
157
+ ).toEqual(["00", "15", "30", "45"]);
158
+
159
+ fireEvent.click(within(column("Sec")).getByRole("button", { name: "20" }));
160
+ await waitFor(() => expect(onChange).toHaveBeenLastCalledWith("2026-09-10 14:30:20"));
161
+ });
162
+
163
+ it("stamps today's date when a time is picked before a day", async () => {
164
+ const onChange = vi.fn();
165
+ render(<Harness initial="" showTime onChange={onChange} />);
166
+
167
+ fireEvent.click(screen.getByText("Select date"));
168
+ await screen.findByText("Hr");
169
+
170
+ fireEvent.click(within(column("Min")).getByRole("button", { name: "45" }));
171
+
172
+ const now = new Date();
173
+ const expected = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(
174
+ now.getDate()
175
+ ).padStart(2, "0")} 00:45`;
176
+ await waitFor(() => expect(onChange).toHaveBeenLastCalledWith(expected));
177
+ });
178
+
179
+ it("reads a date-only value written before showTime was turned on", async () => {
180
+ const onChange = vi.fn();
181
+ render(<Harness initial="2026-09-10" showTime onChange={onChange} />);
182
+
183
+ // Parsed as midnight rather than dropped.
184
+ fireEvent.click(screen.getByText("10 Sep 2026, 12:00 AM"));
185
+ await screen.findByText("Hr");
186
+ fireEvent.click(day(17));
187
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith("2026-09-17 00:00"));
188
+ });
189
+ });