@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/dist/index.cjs +429 -212
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +43 -2
- package/dist/index.d.ts +43 -2
- package/dist/index.js +456 -239
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/__tests__/copyable-cell-tooltip.test.tsx +81 -0
- package/src/__tests__/date-picker-show-time.test.tsx +189 -0
- package/src/__tests__/picker-in-dialog.test.tsx +140 -0
- package/src/copyable-cell.tsx +65 -26
- package/src/date-picker.tsx +459 -126
- package/src/index.ts +1 -1
- package/src/time-picker.tsx +95 -66
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@payglocal_ui/flux-ui",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Flux UI primitives
|
|
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
|
+
});
|
package/src/copyable-cell.tsx
CHANGED
|
@@ -133,9 +133,9 @@ export function CopyableCell({
|
|
|
133
133
|
valueClassName
|
|
134
134
|
);
|
|
135
135
|
|
|
136
|
-
/**
|
|
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" :
|
|
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
|
-
{
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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>
|