@payglocal_ui/flux-ui 0.3.0 → 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,7 +1,7 @@
1
1
  {
2
2
  "name": "@payglocal_ui/flux-ui",
3
- "version": "0.3.0",
4
- "description": "Flux UI primitives \u2014 inputs, fields, dialog, data table, charts, calendar, and more (Tailwind v4 + Radix).",
3
+ "version": "0.3.1",
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",
7
7
  "sideEffects": false,
@@ -63,6 +63,7 @@
63
63
  "lucide-react": "^0.577.0",
64
64
  "next-themes": "^0.4.6",
65
65
  "react-day-picker": "^9.14.0",
66
+ "react-remove-scroll": "^2.7.2",
66
67
  "recharts": "^3.8.0",
67
68
  "sonner": "^2.0.7",
68
69
  "tailwind-merge": "^3.5.0"
@@ -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,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
+ });
@@ -0,0 +1,140 @@
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 { Drawer, DrawerContent } from "../drawer";
5
+ import { DatePicker } from "../date-picker";
6
+ import { TimePicker } from "../time-picker";
7
+
8
+ /**
9
+ * Both pickers portal their panel to `document.body`. A modal Radix Dialog sets
10
+ * `pointer-events: none` on `<body>` for as long as it is open, so a panel that
11
+ * does not re-enable pointer events for itself renders perfectly and answers no
12
+ * click — which is exactly how the MCA "Start a new batch" drawer's date and
13
+ * time fields failed.
14
+ */
15
+
16
+ beforeAll(() => {
17
+ if (!(globalThis as any).PointerEvent) {
18
+ class PE extends MouseEvent {
19
+ pointerType: string;
20
+ constructor(type: string, props: any = {}) {
21
+ super(type, props);
22
+ this.pointerType = props.pointerType ?? "mouse";
23
+ }
24
+ }
25
+ (globalThis as any).PointerEvent = PE;
26
+ }
27
+ if (!(globalThis as any).ResizeObserver) {
28
+ (globalThis as any).ResizeObserver = class {
29
+ observe() {}
30
+ unobserve() {}
31
+ disconnect() {}
32
+ };
33
+ }
34
+ // jsdom implements neither, and both pickers call them when opening.
35
+ Element.prototype.scrollIntoView = vi.fn();
36
+ Element.prototype.scrollTo = vi.fn();
37
+ });
38
+
39
+ function DateHarness({
40
+ onChange,
41
+ showTime = false,
42
+ initial = "2026-09-10",
43
+ }: {
44
+ onChange: (v: string) => void;
45
+ showTime?: boolean;
46
+ initial?: string;
47
+ }) {
48
+ const [value, setValue] = useState(initial);
49
+ return (
50
+ <Drawer open onOpenChange={() => {}} side="right">
51
+ <DrawerContent>
52
+ <DatePicker
53
+ value={value}
54
+ showTime={showTime}
55
+ onChange={(v) => {
56
+ setValue(v);
57
+ onChange(v);
58
+ }}
59
+ />
60
+ </DrawerContent>
61
+ </Drawer>
62
+ );
63
+ }
64
+
65
+ function TimeHarness({ onChange }: { onChange: (v: string) => void }) {
66
+ const [value, setValue] = useState("09:30");
67
+ return (
68
+ <Drawer open onOpenChange={() => {}} side="right">
69
+ <DrawerContent>
70
+ <TimePicker
71
+ value={value}
72
+ onValueChange={(v) => {
73
+ setValue(v);
74
+ onChange(v);
75
+ }}
76
+ />
77
+ </DrawerContent>
78
+ </Drawer>
79
+ );
80
+ }
81
+
82
+ describe("pickers inside a modal drawer", () => {
83
+ it("DatePicker selects a day while the drawer holds body pointer events", async () => {
84
+ const onChange = vi.fn();
85
+ render(<DateHarness onChange={onChange} />);
86
+
87
+ expect(document.body.style.pointerEvents).toBe("none");
88
+
89
+ fireEvent.click(screen.getByText("10 Sep 2026"));
90
+
91
+ const day = await screen.findByRole("button", { name: "17" });
92
+ // The panel must opt back into pointer events, or this click never lands
93
+ // on the day in a real browser.
94
+ const panel = day.closest("[style*='position: fixed']") as HTMLElement;
95
+ expect(panel.style.pointerEvents).toBe("auto");
96
+
97
+ fireEvent.click(day);
98
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith("2026-09-17"));
99
+ });
100
+
101
+ it("TimePicker selects an hour while the drawer holds body pointer events", async () => {
102
+ const onChange = vi.fn();
103
+ render(<TimeHarness onChange={onChange} />);
104
+
105
+ expect(document.body.style.pointerEvents).toBe("none");
106
+
107
+ fireEvent.click(screen.getByText("09:30 AM"));
108
+
109
+ const panel = (await screen.findByText("Hr")).closest(
110
+ "[style*='position: fixed']"
111
+ ) as HTMLElement;
112
+ expect(panel.style.pointerEvents).toBe("auto");
113
+
114
+ // "11" appears once per column pair; the hour column is the first.
115
+ const eleven = screen.getAllByText("11")[0];
116
+ fireEvent.click(eleven);
117
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith("11:30"));
118
+ });
119
+
120
+ it("DatePicker's time columns are clickable inside the drawer too", async () => {
121
+ const onChange = vi.fn();
122
+ render(
123
+ <DateHarness onChange={onChange} showTime initial="2026-09-10 14:30" />
124
+ );
125
+
126
+ expect(document.body.style.pointerEvents).toBe("none");
127
+
128
+ fireEvent.click(screen.getByText("10 Sep 2026, 02:30 PM"));
129
+
130
+ // The whole panel, time columns and footer included, opts back in.
131
+ const panel = (await screen.findByText("OK")).closest(
132
+ "[style*='position: fixed']"
133
+ ) as HTMLElement;
134
+ expect(panel.style.pointerEvents).toBe("auto");
135
+
136
+ const minutes = screen.getByText("Min").parentElement as HTMLElement;
137
+ fireEvent.click(within(minutes).getByRole("button", { name: "45" }));
138
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith("2026-09-10 14:45"));
139
+ });
140
+ });
@@ -133,9 +133,9 @@ export function CopyableCell({
133
133
  valueClassName
134
134
  );
135
135
 
136
- /** Names the full value when the screen only shows part of it. */
136
+ /** True when the screen is only showing part of the value. */
137
137
  const elided = display !== undefined && display !== value;
138
- const copyHint = copied ? "Copied" : elided ? `Copy ${value}` : `Copy ${label}`;
138
+ const copyHint = copied ? "Copied" : `Copy ${label}`;
139
139
 
140
140
  if (variant === "cell") {
141
141
  // The whole box copies, so there is no separate button to reach for — in a
@@ -187,35 +187,74 @@ export function CopyableCell({
187
187
  );
188
188
  }
189
189
 
190
+ /**
191
+ * The value as drawn. An elided value carries the whole thing in the tooltip
192
+ * below; `title` is only for the other case — a value `display` did not
193
+ * shorten can still be cut by the column's own `truncate`, and there is no
194
+ * React-side way to know that it was.
195
+ */
196
+ const valueNode = onClick ? (
197
+ // A bare <button>, deliberately, where the rest of flux would reach for
198
+ // `Button variant="link"`. That variant hard-codes `text-[15px]`, and
199
+ // the obvious override — `text-[inherit]` — does not beat it: Tailwind
200
+ // and tailwind-merge read `text-[<non-length>]` as a COLOUR, so the
201
+ // size class survives and the cell renders 2px larger than every other
202
+ // cell in the row. Preflight already gives a bare button `font: inherit`,
203
+ // so this simply inherits the cell's size at whatever density the table
204
+ // is using, which is what a cell should do.
205
+ <button
206
+ type="button"
207
+ onClick={onClick}
208
+ title={elided ? undefined : value}
209
+ // The elided text is not the name of this control — the value is.
210
+ aria-label={elided ? value : undefined}
211
+ className={cn(
212
+ "cursor-pointer bg-transparent p-0 text-left underline-offset-4",
213
+ "hover:underline focus-visible:underline focus-visible:outline-none",
214
+ text
215
+ )}
216
+ >
217
+ {display ?? value}
218
+ </button>
219
+ ) : (
220
+ <span
221
+ className={text}
222
+ title={elided ? undefined : value}
223
+ // Hidden from assistive tech only when there is an sr-only twin below
224
+ // carrying the whole value — otherwise the elided form would be read out
225
+ // as well as it, twice for one id.
226
+ aria-hidden={elided || undefined}
227
+ >
228
+ {display ?? value}
229
+ </span>
230
+ );
231
+
190
232
  return (
191
233
  <div className={cn("group/copy flex min-w-0 items-center gap-1", className)}>
192
- {onClick ? (
193
- // A bare <button>, deliberately, where the rest of flux would reach for
194
- // `Button variant="link"`. That variant hard-codes `text-[15px]`, and
195
- // the obvious override — `text-[inherit]` — does not beat it: Tailwind
196
- // and tailwind-merge read `text-[<non-length>]` as a COLOUR, so the
197
- // size class survives and the cell renders 2px larger than every other
198
- // cell in the row. Preflight already gives a bare button `font: inherit`,
199
- // so this simply inherits the cell's size at whatever density the table
200
- // is using, which is what a cell should do.
201
- <button
202
- type="button"
203
- onClick={onClick}
204
- title={value}
205
- className={cn(
206
- "cursor-pointer bg-transparent p-0 text-left underline-offset-4",
207
- "hover:underline focus-visible:underline focus-visible:outline-none",
208
- text
209
- )}
210
- >
211
- {display ?? value}
212
- </button>
234
+ {/* The full value hangs off the **text**, which is the part that was
235
+ shortened and so the part a reader reaches for. It used to hang off
236
+ the copy button instead, which meant the way to read an id was to
237
+ hover the control that copies it. Only mounted when something was
238
+ actually elided: a tooltip repeating text already fully on screen is
239
+ noise. */}
240
+ {elided ? (
241
+ <TooltipProvider delayDuration={200}>
242
+ <Tooltip>
243
+ <TooltipTrigger asChild>{valueNode}</TooltipTrigger>
244
+ <TooltipContent side="top" className="text-xs">
245
+ {value}
246
+ </TooltipContent>
247
+ </Tooltip>
248
+ </TooltipProvider>
213
249
  ) : (
214
- <span className={text} title={value}>
215
- {display ?? value}
216
- </span>
250
+ valueNode
217
251
  )}
218
252
 
253
+ {/* Elided text reads as noise aloud, so the full value is what goes to a
254
+ screen reader. The interactive case names itself with `aria-label`
255
+ above and needs none of this. */}
256
+ {elided && !onClick && <span className="sr-only">{value}</span>}
257
+
219
258
  <TooltipProvider delayDuration={200}>
220
259
  <Tooltip>
221
260
  <TooltipTrigger asChild>