@kinetixui/ui 0.23.2 → 0.24.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/README.md +134 -0
- package/dist/index.d.ts +212 -31
- package/dist/index.js +1466 -1118
- package/dist/kx-src-hash.json +3 -0
- package/package.json +7 -7
- package/src/components/accordion.tsx +1 -1
- package/src/components/alert-dialog.tsx +7 -5
- package/src/components/avatar.tsx +4 -1
- package/src/components/banner.tsx +9 -3
- package/src/components/breadcrumb.tsx +1 -1
- package/src/components/button.tsx +1 -1
- package/src/components/card.tsx +77 -6
- package/src/components/checkbox.tsx +42 -8
- package/src/components/collapsible.tsx +41 -1
- package/src/components/command.tsx +2 -2
- package/src/components/context-menu.tsx +21 -13
- package/src/components/dialog.tsx +5 -4
- package/src/components/direction-provider.tsx +85 -1
- package/src/components/drawer.tsx +62 -18
- package/src/components/dropdown-menu.tsx +12 -4
- package/src/components/file-upload.tsx +1 -1
- package/src/components/hover-card.tsx +1 -1
- package/src/components/inform.tsx +9 -3
- package/src/components/input-group.tsx +27 -5
- package/src/components/input-otp.tsx +48 -5
- package/src/components/input.tsx +20 -3
- package/src/components/json-viewer.tsx +1 -1
- package/src/components/menubar.tsx +21 -15
- package/src/components/modal.tsx +3 -2
- package/src/components/multi-select.tsx +14 -5
- package/src/components/native-select.tsx +10 -2
- package/src/components/navigation-bar.tsx +1 -1
- package/src/components/navigation-menu.tsx +6 -6
- package/src/components/number-input.tsx +28 -10
- package/src/components/pagination.tsx +2 -2
- package/src/components/popover.tsx +29 -5
- package/src/components/radio-group.tsx +23 -8
- package/src/components/segmented-control.tsx +20 -4
- package/src/components/select.tsx +25 -8
- package/src/components/sheet.tsx +4 -3
- package/src/components/spinner.tsx +1 -1
- package/src/components/switch.tsx +31 -8
- package/src/components/table.tsx +121 -8
- package/src/components/tabs.tsx +29 -4
- package/src/components/tag.tsx +1 -1
- package/src/components/textarea.tsx +20 -3
- package/src/components/tooltip.tsx +14 -4
- package/src/components/tour.tsx +22 -7
- package/src/components/tree-view.tsx +1 -1
- package/src/components-behavior.test.tsx +74 -0
- package/src/components-direction-portal.test.tsx +482 -0
- package/src/components-form.test.tsx +435 -0
- package/src/components-icons.test.tsx +197 -0
- package/src/components-keyboard.test.tsx +542 -2
- package/src/components-overlay.test.tsx +381 -0
- package/src/components-rtl.test.tsx +258 -0
- package/src/components-selection.test.tsx +207 -0
- package/src/components-table-scroll.test.tsx +363 -0
- package/src/index.ts +2 -0
- package/src/navigation-menu-focus.test.tsx +35 -0
- package/src/stories/Card.stories.tsx +81 -1
- package/src/stories/Checkbox.stories.tsx +23 -1
- package/src/stories/ContextMenu.stories.tsx +1 -1
- package/src/stories/Input.stories.tsx +34 -0
- package/src/stories/InputGroup.stories.tsx +96 -2
- package/src/stories/InputOTP.stories.tsx +24 -1
- package/src/stories/MultiSelect.stories.tsx +25 -1
- package/src/stories/NativeSelect.stories.tsx +24 -1
- package/src/stories/NumberInput.stories.tsx +20 -1
- package/src/stories/Progress.stories.tsx +1 -1
- package/src/stories/RadioGroup.stories.tsx +33 -1
- package/src/stories/SegmentedControl.stories.tsx +21 -1
- package/src/stories/Select.stories.tsx +28 -1
- package/src/stories/Slider.stories.tsx +1 -1
- package/src/stories/Switch.stories.tsx +37 -1
- package/src/stories/Table.stories.tsx +35 -1
- package/src/stories/Tabs.stories.tsx +27 -2
- package/src/stories/Textarea.stories.tsx +34 -0
- package/src/usage/icons.tsx +60 -0
- package/tailwind.config.ts +118 -22
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { KinetixDirectionProvider } from "./components/direction-provider";
|
|
6
|
+
import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerTitle, DrawerTrigger } from "./components/drawer";
|
|
7
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./components/popover";
|
|
8
|
+
import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "./components/dialog";
|
|
9
|
+
import {
|
|
10
|
+
DropdownMenu,
|
|
11
|
+
DropdownMenuContent,
|
|
12
|
+
DropdownMenuItem,
|
|
13
|
+
DropdownMenuSub,
|
|
14
|
+
DropdownMenuSubContent,
|
|
15
|
+
DropdownMenuSubTrigger,
|
|
16
|
+
DropdownMenuTrigger,
|
|
17
|
+
} from "./components/dropdown-menu";
|
|
18
|
+
import { Sheet, SheetContent, SheetDescription, SheetTitle, SheetTrigger } from "./components/sheet";
|
|
19
|
+
import { Modal } from "./components/modal";
|
|
20
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./components/tooltip";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* components-overlay.test.tsx — the overlay family's open/close state contract, and the two defects the
|
|
24
|
+
* open-state scan found.
|
|
25
|
+
*
|
|
26
|
+
* Separate from `components-keyboard.test.tsx` for one reason worth stating: that file is the single
|
|
27
|
+
* source `scripts/gen-keyboard.mjs` publishes each component's keyboard model from, so what belongs there
|
|
28
|
+
* is keyboard behaviour. Controlled-vs-uncontrolled open state, a `role="dialog"` having a name, and
|
|
29
|
+
* where focus lands are not keyboard models, and documenting them as one would misdescribe them.
|
|
30
|
+
*
|
|
31
|
+
* `Drawer` also has to live outside that module. Radix's anchored ContextMenu stops opening once any
|
|
32
|
+
* modal overlay has mounted there, and vaul's Drawer is one — the ordering constraint is recorded at the
|
|
33
|
+
* top of the ContextMenu suite in that file.
|
|
34
|
+
*
|
|
35
|
+
* kx-verify: interaction
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
describe("Popover's accessible name", () => {
|
|
39
|
+
it("names the dialog Radix gives it, because an unnamed one announces only its role", async () => {
|
|
40
|
+
const user = userEvent.setup();
|
|
41
|
+
render(
|
|
42
|
+
<Popover>
|
|
43
|
+
<PopoverTrigger>Open</PopoverTrigger>
|
|
44
|
+
<PopoverContent>
|
|
45
|
+
<p>Dimensions</p>
|
|
46
|
+
</PopoverContent>
|
|
47
|
+
</Popover>,
|
|
48
|
+
);
|
|
49
|
+
await user.click(screen.getByRole("button", { name: "Open" }));
|
|
50
|
+
// Found by running axe against the open surface — a check that did not exist before this slice,
|
|
51
|
+
// because the suite scanned overlay stories closed. Every Popover in the library was a dialog with
|
|
52
|
+
// no accessible name, which WCAG 4.1.2 fails and a screen reader reads as the single word "dialog".
|
|
53
|
+
expect(await screen.findByRole("dialog", { name: "Popover" })).toBeInTheDocument();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("keeps the caller's own name, and stays out of the way of aria-labelledby", async () => {
|
|
57
|
+
const user = userEvent.setup();
|
|
58
|
+
const { unmount } = render(
|
|
59
|
+
<Popover>
|
|
60
|
+
<PopoverTrigger>A</PopoverTrigger>
|
|
61
|
+
<PopoverContent aria-label="Layer dimensions">
|
|
62
|
+
<p>x</p>
|
|
63
|
+
</PopoverContent>
|
|
64
|
+
</Popover>,
|
|
65
|
+
);
|
|
66
|
+
await user.click(screen.getByRole("button", { name: "A" }));
|
|
67
|
+
expect(await screen.findByRole("dialog", { name: "Layer dimensions" })).toBeInTheDocument();
|
|
68
|
+
unmount();
|
|
69
|
+
|
|
70
|
+
render(
|
|
71
|
+
<Popover>
|
|
72
|
+
<PopoverTrigger>B</PopoverTrigger>
|
|
73
|
+
<PopoverContent aria-labelledby="heading">
|
|
74
|
+
<h2 id="heading">Dimensions</h2>
|
|
75
|
+
</PopoverContent>
|
|
76
|
+
</Popover>,
|
|
77
|
+
);
|
|
78
|
+
await user.click(screen.getByRole("button", { name: "B" }));
|
|
79
|
+
const named = await screen.findByRole("dialog", { name: "Dimensions" });
|
|
80
|
+
// The fallback must not shadow a heading the caller pointed at: an aria-label would win over
|
|
81
|
+
// aria-labelledby and the name would silently become "Popover" again.
|
|
82
|
+
expect(named).not.toHaveAttribute("aria-label");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("survives the ordinary conditional-prop spelling, where the name arrives as undefined", async () => {
|
|
86
|
+
const user = userEvent.setup();
|
|
87
|
+
const label: string | undefined = undefined;
|
|
88
|
+
render(
|
|
89
|
+
<Popover>
|
|
90
|
+
<PopoverTrigger>C</PopoverTrigger>
|
|
91
|
+
{/* `aria-label={maybe}` is how a caller passes an optional name, and it puts the key in props
|
|
92
|
+
with the value undefined. Read off a spread props object, that undefined is spread back over
|
|
93
|
+
the computed fallback and DELETES the attribute — so the unnamed dialog this fallback exists
|
|
94
|
+
to prevent comes back through the most ordinary call site there is. */}
|
|
95
|
+
<PopoverContent aria-label={label}>
|
|
96
|
+
<p>x</p>
|
|
97
|
+
</PopoverContent>
|
|
98
|
+
</Popover>,
|
|
99
|
+
);
|
|
100
|
+
await user.click(screen.getByRole("button", { name: "C" }));
|
|
101
|
+
expect(await screen.findByRole("dialog", { name: "Popover" })).toBeInTheDocument();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("Drawer focus entry", () => {
|
|
106
|
+
function Example() {
|
|
107
|
+
return (
|
|
108
|
+
<Drawer>
|
|
109
|
+
<DrawerTrigger>Open Drawer</DrawerTrigger>
|
|
110
|
+
<DrawerContent>
|
|
111
|
+
<DrawerTitle>Move goal</DrawerTitle>
|
|
112
|
+
<DrawerDescription>Set your daily activity goal.</DrawerDescription>
|
|
113
|
+
<DrawerClose>Cancel</DrawerClose>
|
|
114
|
+
</DrawerContent>
|
|
115
|
+
</Drawer>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
it("moves focus into the panel, and not onto the trigger the drawer has just hidden", async () => {
|
|
120
|
+
const user = userEvent.setup();
|
|
121
|
+
render(<Example />);
|
|
122
|
+
const trigger = screen.getByRole("button", { name: "Open Drawer" });
|
|
123
|
+
await user.click(trigger);
|
|
124
|
+
const panel = await screen.findByRole("dialog");
|
|
125
|
+
|
|
126
|
+
// Measured in Chromium before this was fixed: focus stayed on the trigger, which by then sits inside
|
|
127
|
+
// a subtree the drawer marks aria-hidden — so a screen-reader user was left on an element their
|
|
128
|
+
// software had just been told does not exist. The panel itself is the destination rather than the
|
|
129
|
+
// first control in it, so a drawer containing a field does not summon a mobile keyboard on open.
|
|
130
|
+
await waitFor(() => expect(document.activeElement).toBe(panel));
|
|
131
|
+
expect(trigger).not.toHaveFocus();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("still enters the panel when the caller also listens for the open-focus event", async () => {
|
|
135
|
+
const user = userEvent.setup();
|
|
136
|
+
const seen = vi.fn();
|
|
137
|
+
render(
|
|
138
|
+
<Drawer>
|
|
139
|
+
<DrawerTrigger>Open Drawer</DrawerTrigger>
|
|
140
|
+
{/* A consumer handler that only observes. With `{...props}` trailing it would REPLACE the one
|
|
141
|
+
this component installs, and because vaul suppresses auto-focus otherwise, focus would be left
|
|
142
|
+
on the trigger the drawer has just hidden — the defect back, via a prop that does nothing. */}
|
|
143
|
+
<DrawerContent onOpenAutoFocus={seen}>
|
|
144
|
+
<DrawerTitle>Move goal</DrawerTitle>
|
|
145
|
+
<DrawerClose>Cancel</DrawerClose>
|
|
146
|
+
</DrawerContent>
|
|
147
|
+
</Drawer>,
|
|
148
|
+
);
|
|
149
|
+
await user.click(screen.getByRole("button", { name: "Open Drawer" }));
|
|
150
|
+
const panel = await screen.findByRole("dialog");
|
|
151
|
+
await waitFor(() => expect(document.activeElement).toBe(panel));
|
|
152
|
+
expect(seen).toHaveBeenCalledTimes(1);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("leaves focus to a caller who takes it over deliberately", async () => {
|
|
156
|
+
const user = userEvent.setup();
|
|
157
|
+
render(
|
|
158
|
+
<Drawer>
|
|
159
|
+
<DrawerTrigger>Open Drawer</DrawerTrigger>
|
|
160
|
+
{/* Calling preventDefault is how a caller says they are placing focus themselves. Composition has
|
|
161
|
+
to mean composition and not "ours always wins", or an author who wants focus on their own
|
|
162
|
+
control cannot have it. */}
|
|
163
|
+
<DrawerContent
|
|
164
|
+
onOpenAutoFocus={(event) => {
|
|
165
|
+
event.preventDefault();
|
|
166
|
+
document.getElementById("mine")?.focus();
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
<DrawerTitle>Move goal</DrawerTitle>
|
|
170
|
+
<button id="mine" type="button">
|
|
171
|
+
Mine
|
|
172
|
+
</button>
|
|
173
|
+
</DrawerContent>
|
|
174
|
+
</Drawer>,
|
|
175
|
+
);
|
|
176
|
+
await user.click(screen.getByRole("button", { name: "Open Drawer" }));
|
|
177
|
+
await screen.findByRole("dialog");
|
|
178
|
+
await waitFor(() => expect(screen.getByRole("button", { name: "Mine" })).toHaveFocus());
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("closes on Escape and gives focus back", async () => {
|
|
182
|
+
const user = userEvent.setup();
|
|
183
|
+
render(<Example />);
|
|
184
|
+
const trigger = screen.getByRole("button", { name: "Open Drawer" });
|
|
185
|
+
await user.click(trigger);
|
|
186
|
+
await screen.findByRole("dialog");
|
|
187
|
+
await user.keyboard("{Escape}");
|
|
188
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
|
189
|
+
expect(trigger).toHaveFocus();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("Overlay open state", () => {
|
|
194
|
+
it("Dialog stays closed when controlled, and reports the request once", async () => {
|
|
195
|
+
const user = userEvent.setup();
|
|
196
|
+
const onOpenChange = vi.fn();
|
|
197
|
+
render(
|
|
198
|
+
<Dialog open={false} onOpenChange={onOpenChange}>
|
|
199
|
+
<DialogTrigger>Open</DialogTrigger>
|
|
200
|
+
<DialogContent>
|
|
201
|
+
<DialogTitle>T</DialogTitle>
|
|
202
|
+
<DialogDescription>D</DialogDescription>
|
|
203
|
+
</DialogContent>
|
|
204
|
+
</Dialog>,
|
|
205
|
+
);
|
|
206
|
+
await user.click(screen.getByRole("button", { name: "Open" }));
|
|
207
|
+
expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(true);
|
|
208
|
+
// Controlled means controlled: the surface must not appear on its own.
|
|
209
|
+
expect(screen.queryByRole("dialog")).toBeNull();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("Dialog opens from defaultOpen and can be closed and reopened", async () => {
|
|
213
|
+
const user = userEvent.setup();
|
|
214
|
+
render(
|
|
215
|
+
<Dialog defaultOpen>
|
|
216
|
+
<DialogTrigger>Open</DialogTrigger>
|
|
217
|
+
<DialogContent>
|
|
218
|
+
<DialogTitle>Reopenable</DialogTitle>
|
|
219
|
+
<DialogDescription>D</DialogDescription>
|
|
220
|
+
</DialogContent>
|
|
221
|
+
</Dialog>,
|
|
222
|
+
);
|
|
223
|
+
await screen.findByRole("dialog", { name: "Reopenable" });
|
|
224
|
+
await user.keyboard("{Escape}");
|
|
225
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
|
226
|
+
|
|
227
|
+
// Reopening is the half that stale state breaks, so it is the half worth asserting.
|
|
228
|
+
await user.click(screen.getByRole("button", { name: "Open" }));
|
|
229
|
+
expect(await screen.findByRole("dialog", { name: "Reopenable" })).toBeInTheDocument();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("Sheet's close button dismisses it and returns focus", async () => {
|
|
233
|
+
const user = userEvent.setup();
|
|
234
|
+
render(
|
|
235
|
+
<Sheet>
|
|
236
|
+
<SheetTrigger>Open</SheetTrigger>
|
|
237
|
+
<SheetContent>
|
|
238
|
+
<SheetTitle>Panel</SheetTitle>
|
|
239
|
+
<SheetDescription>D</SheetDescription>
|
|
240
|
+
</SheetContent>
|
|
241
|
+
</Sheet>,
|
|
242
|
+
);
|
|
243
|
+
const trigger = screen.getByRole("button", { name: "Open" });
|
|
244
|
+
await user.click(trigger);
|
|
245
|
+
await screen.findByRole("dialog", { name: "Panel" });
|
|
246
|
+
|
|
247
|
+
await user.click(screen.getByRole("button", { name: "Close" }));
|
|
248
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
|
249
|
+
expect(trigger).toHaveFocus();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("Modal's own buttons carry its decisions, and each fires once", async () => {
|
|
253
|
+
const user = userEvent.setup();
|
|
254
|
+
const onAction = vi.fn();
|
|
255
|
+
const onCancel = vi.fn();
|
|
256
|
+
render(
|
|
257
|
+
<Modal
|
|
258
|
+
type="Confirmation"
|
|
259
|
+
title="Confirm it"
|
|
260
|
+
description="Sure?"
|
|
261
|
+
trigger={<button>Go</button>}
|
|
262
|
+
onAction={onAction}
|
|
263
|
+
onCancel={onCancel}
|
|
264
|
+
/>,
|
|
265
|
+
);
|
|
266
|
+
await user.click(screen.getByRole("button", { name: "Go" }));
|
|
267
|
+
await screen.findByRole("dialog", { name: "Confirm it" });
|
|
268
|
+
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
|
269
|
+
expect(onCancel).toHaveBeenCalledOnce();
|
|
270
|
+
expect(onAction).not.toHaveBeenCalled();
|
|
271
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
|
272
|
+
|
|
273
|
+
await user.click(screen.getByRole("button", { name: "Go" }));
|
|
274
|
+
await screen.findByRole("dialog", { name: "Confirm it" });
|
|
275
|
+
await user.click(screen.getByRole("button", { name: "Confirm" }));
|
|
276
|
+
expect(onAction).toHaveBeenCalledOnce();
|
|
277
|
+
expect(onCancel).toHaveBeenCalledOnce(); // still once: confirming is not cancelling
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("Popover closes on Escape and hands focus back, without trapping it the way a dialog does", async () => {
|
|
281
|
+
const user = userEvent.setup();
|
|
282
|
+
render(
|
|
283
|
+
<>
|
|
284
|
+
<Popover>
|
|
285
|
+
<PopoverTrigger>Open</PopoverTrigger>
|
|
286
|
+
<PopoverContent aria-label="Panel">
|
|
287
|
+
<button>Inside</button>
|
|
288
|
+
</PopoverContent>
|
|
289
|
+
</Popover>
|
|
290
|
+
<button>After</button>
|
|
291
|
+
</>,
|
|
292
|
+
);
|
|
293
|
+
const trigger = screen.getByRole("button", { name: "Open" });
|
|
294
|
+
await user.click(trigger);
|
|
295
|
+
const surface = await screen.findByRole("dialog", { name: "Panel" });
|
|
296
|
+
// Non-modal, so `openStates` declares `modal: false` and a11y-browser asserts there that Tab can
|
|
297
|
+
// leave it. That half is deliberately not asserted here: in jsdom focus stays on the one control
|
|
298
|
+
// inside the surface however many times Tab is pressed, which is the focus scope's loop behaving
|
|
299
|
+
// differently without layout, not the component trapping. A browser answers it; this cannot.
|
|
300
|
+
expect(surface).toBeInTheDocument();
|
|
301
|
+
|
|
302
|
+
await user.keyboard("{Escape}");
|
|
303
|
+
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
|
304
|
+
expect(trigger).toHaveFocus();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("Tooltip does not trap focus either, and closes on Escape", async () => {
|
|
308
|
+
const user = userEvent.setup();
|
|
309
|
+
render(
|
|
310
|
+
<TooltipProvider>
|
|
311
|
+
<Tooltip>
|
|
312
|
+
<TooltipTrigger>Library</TooltipTrigger>
|
|
313
|
+
<TooltipContent>Add to library</TooltipContent>
|
|
314
|
+
</Tooltip>
|
|
315
|
+
</TooltipProvider>,
|
|
316
|
+
);
|
|
317
|
+
await user.tab();
|
|
318
|
+
await waitFor(() => expect(screen.getByRole("tooltip")).toBeInTheDocument());
|
|
319
|
+
await user.keyboard("{Escape}");
|
|
320
|
+
await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument());
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// kx-verify: interaction, rtl
|
|
325
|
+
|
|
326
|
+
describe("Overlay portals under RTL", () => {
|
|
327
|
+
const rtl = (node: React.ReactNode) => <KinetixDirectionProvider dir="rtl">{node}</KinetixDirectionProvider>;
|
|
328
|
+
|
|
329
|
+
it("resolves a submenu onto the start side, which is the direction reaching the portal", async () => {
|
|
330
|
+
const user = userEvent.setup();
|
|
331
|
+
function Example() {
|
|
332
|
+
return (
|
|
333
|
+
<DropdownMenu>
|
|
334
|
+
<DropdownMenuTrigger>Actions</DropdownMenuTrigger>
|
|
335
|
+
<DropdownMenuContent>
|
|
336
|
+
<DropdownMenuSub>
|
|
337
|
+
<DropdownMenuSubTrigger>More</DropdownMenuSubTrigger>
|
|
338
|
+
<DropdownMenuSubContent>
|
|
339
|
+
<DropdownMenuItem>Nested</DropdownMenuItem>
|
|
340
|
+
</DropdownMenuSubContent>
|
|
341
|
+
</DropdownMenuSub>
|
|
342
|
+
</DropdownMenuContent>
|
|
343
|
+
</DropdownMenu>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
render(rtl(<Example />));
|
|
347
|
+
screen.getByRole("button", { name: "Actions" }).focus();
|
|
348
|
+
await user.keyboard("{Enter}");
|
|
349
|
+
await screen.findByRole("menu");
|
|
350
|
+
|
|
351
|
+
// The submenu is portaled out of the provider's subtree, so nothing in the DOM carries the direction
|
|
352
|
+
// to it — CSS inheritance cannot, because the portal is a child of <body>. What does reach it is
|
|
353
|
+
// Radix's direction context, and this is the observable consequence: a submenu that opens toward the
|
|
354
|
+
// inline start resolves `data-side="left"`, where under LTR it resolves "right". The computed CSS
|
|
355
|
+
// `direction` on portal content is a different claim and belongs to the browser pass, which sets
|
|
356
|
+
// `dir` on the document the way an RTL app does.
|
|
357
|
+
screen.getByRole("menuitem", { name: "More" }).focus();
|
|
358
|
+
await user.keyboard("{ArrowLeft}");
|
|
359
|
+
const nested = await screen.findByRole("menuitem", { name: "Nested" });
|
|
360
|
+
const sub = nested.closest('[role="menu"]');
|
|
361
|
+
expect(sub?.getAttribute("data-side")).toBe("left");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("keeps Popover's fallback name when the surface is portaled under RTL", async () => {
|
|
365
|
+
const user = userEvent.setup();
|
|
366
|
+
render(
|
|
367
|
+
rtl(
|
|
368
|
+
<Popover>
|
|
369
|
+
<PopoverTrigger>افتح</PopoverTrigger>
|
|
370
|
+
<PopoverContent>
|
|
371
|
+
<p>المحتوى</p>
|
|
372
|
+
</PopoverContent>
|
|
373
|
+
</Popover>,
|
|
374
|
+
),
|
|
375
|
+
);
|
|
376
|
+
await user.click(screen.getByRole("button", { name: "افتح" }));
|
|
377
|
+
// The name has to survive the portal and the direction both, or an RTL app gets the unnamed dialog
|
|
378
|
+
// back without anything saying so.
|
|
379
|
+
expect(await screen.findByRole("dialog", { name: "Popover" })).toBeInTheDocument();
|
|
380
|
+
});
|
|
381
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { KinetixDirectionProvider } from "./components/direction-provider";
|
|
6
|
+
import {
|
|
7
|
+
ContextMenu,
|
|
8
|
+
ContextMenuCheckboxItem,
|
|
9
|
+
ContextMenuContent,
|
|
10
|
+
ContextMenuItem,
|
|
11
|
+
ContextMenuShortcut,
|
|
12
|
+
ContextMenuSub,
|
|
13
|
+
ContextMenuSubContent,
|
|
14
|
+
ContextMenuSubTrigger,
|
|
15
|
+
ContextMenuTrigger,
|
|
16
|
+
} from "./components/context-menu";
|
|
17
|
+
import {
|
|
18
|
+
Menubar,
|
|
19
|
+
MenubarContent,
|
|
20
|
+
MenubarItem,
|
|
21
|
+
MenubarMenu,
|
|
22
|
+
MenubarShortcut,
|
|
23
|
+
MenubarTrigger,
|
|
24
|
+
} from "./components/menubar";
|
|
25
|
+
import {
|
|
26
|
+
NavigationMenu,
|
|
27
|
+
NavigationMenuContent,
|
|
28
|
+
NavigationMenuItem,
|
|
29
|
+
NavigationMenuList,
|
|
30
|
+
NavigationMenuTrigger,
|
|
31
|
+
} from "./components/navigation-menu";
|
|
32
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./components/popover";
|
|
33
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./components/tooltip";
|
|
34
|
+
import {
|
|
35
|
+
AlertDialog,
|
|
36
|
+
AlertDialogContent,
|
|
37
|
+
AlertDialogDescription,
|
|
38
|
+
AlertDialogAction,
|
|
39
|
+
AlertDialogCancel,
|
|
40
|
+
AlertDialogFooter,
|
|
41
|
+
AlertDialogHeader,
|
|
42
|
+
AlertDialogTitle,
|
|
43
|
+
} from "./components/alert-dialog";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* components-rtl.test.tsx — the overlay family mirrors under `dir="rtl"`.
|
|
47
|
+
*
|
|
48
|
+
* Slice 2 of the RTL conversion (see RTL.md). Slice 1 converted Dialog, Sheet, Drawer, DropdownMenu and
|
|
49
|
+
* Select; this covers the rest of the overlay family — ContextMenu, Menubar, AlertDialog, NavigationMenu,
|
|
50
|
+
* Popover and Tooltip.
|
|
51
|
+
*
|
|
52
|
+
* `scripts/check-rtl.mjs` is a source guardrail: it proves no physical-direction class is left in the file.
|
|
53
|
+
* It cannot prove the class that replaced it is the *right* one, or that the Radix primitive underneath
|
|
54
|
+
* actually flips — which is the failure RTL.md records as the one that cost a slice to find. These are the
|
|
55
|
+
* behavioural half: the rendered DOM carries the logical class, and the keyboard direction mirrors.
|
|
56
|
+
*
|
|
57
|
+
* jsdom has no layout, so these assert the contract (class names, direction-dependent key handling) rather
|
|
58
|
+
* than measured geometry. Pixel mirroring is the browser pass's job.
|
|
59
|
+
*
|
|
60
|
+
* kx-verify: rtl
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
const rtl = (node: React.ReactNode) => <KinetixDirectionProvider dir="rtl">{node}</KinetixDirectionProvider>;
|
|
64
|
+
|
|
65
|
+
describe("ContextMenu under RTL", () => {
|
|
66
|
+
function Example() {
|
|
67
|
+
return (
|
|
68
|
+
<ContextMenu>
|
|
69
|
+
<ContextMenuTrigger>target</ContextMenuTrigger>
|
|
70
|
+
<ContextMenuContent>
|
|
71
|
+
<ContextMenuItem inset>Inset item</ContextMenuItem>
|
|
72
|
+
<ContextMenuCheckboxItem checked>Checked</ContextMenuCheckboxItem>
|
|
73
|
+
<ContextMenuItem>
|
|
74
|
+
Save <ContextMenuShortcut>⌘S</ContextMenuShortcut>
|
|
75
|
+
</ContextMenuItem>
|
|
76
|
+
<ContextMenuSub>
|
|
77
|
+
<ContextMenuSubTrigger>More</ContextMenuSubTrigger>
|
|
78
|
+
<ContextMenuSubContent>
|
|
79
|
+
<ContextMenuItem>Nested</ContextMenuItem>
|
|
80
|
+
</ContextMenuSubContent>
|
|
81
|
+
</ContextMenuSub>
|
|
82
|
+
</ContextMenuContent>
|
|
83
|
+
</ContextMenu>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
it("mirrors its gutters, shortcut and submenu direction", async () => {
|
|
88
|
+
// One open, many assertions: Radix's ContextMenu is driven by a real contextmenu event and does not
|
|
89
|
+
// re-open cleanly across renders in jsdom, so re-opening per assertion would be testing the harness.
|
|
90
|
+
const user = userEvent.setup();
|
|
91
|
+
render(rtl(<Example />));
|
|
92
|
+
fireEvent.contextMenu(screen.getByText("target"));
|
|
93
|
+
await screen.findByRole("menu");
|
|
94
|
+
|
|
95
|
+
// Inset items indent on the inline start, not the physical left.
|
|
96
|
+
expect(screen.getByText("Inset item")).toHaveClass("ps-8");
|
|
97
|
+
expect(screen.getByText("Inset item").className).not.toMatch(/\bpl-8\b/);
|
|
98
|
+
|
|
99
|
+
// The tick gutter must move to the right-hand side under RTL.
|
|
100
|
+
const checkbox = screen.getByRole("menuitemcheckbox", { name: /Checked/ });
|
|
101
|
+
expect(checkbox.querySelector(".start-2")).not.toBeNull();
|
|
102
|
+
expect(checkbox.querySelector(".left-2")).toBeNull();
|
|
103
|
+
|
|
104
|
+
// The shortcut pushes to the inline end.
|
|
105
|
+
expect(screen.getByText("⌘S")).toHaveClass("ms-auto");
|
|
106
|
+
|
|
107
|
+
// In a right-to-left menu the submenu unfolds toward the left, so ArrowLeft is "into" it.
|
|
108
|
+
const sub = screen.getByRole("menuitem", { name: "More" });
|
|
109
|
+
sub.focus();
|
|
110
|
+
await user.keyboard("{ArrowLeft}");
|
|
111
|
+
expect(await screen.findByRole("menuitem", { name: "Nested" })).toBeInTheDocument();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("Menubar under RTL", () => {
|
|
116
|
+
it("places the shortcut on the inline end", async () => {
|
|
117
|
+
const user = userEvent.setup();
|
|
118
|
+
render(
|
|
119
|
+
rtl(
|
|
120
|
+
<Menubar>
|
|
121
|
+
<MenubarMenu>
|
|
122
|
+
<MenubarTrigger>File</MenubarTrigger>
|
|
123
|
+
<MenubarContent>
|
|
124
|
+
<MenubarItem inset>
|
|
125
|
+
New <MenubarShortcut>⌘N</MenubarShortcut>
|
|
126
|
+
</MenubarItem>
|
|
127
|
+
</MenubarContent>
|
|
128
|
+
</MenubarMenu>
|
|
129
|
+
</Menubar>,
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
const trigger = screen.getByRole("menuitem", { name: "File" });
|
|
133
|
+
trigger.focus();
|
|
134
|
+
await user.keyboard("{Enter}");
|
|
135
|
+
await screen.findByRole("menu");
|
|
136
|
+
expect(screen.getByText("⌘N")).toHaveClass("ms-auto");
|
|
137
|
+
expect(screen.getByRole("menuitem", { name: /New/ })).toHaveClass("ps-8");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("AlertDialog under RTL", () => {
|
|
142
|
+
it("aligns its header to the reading direction and stays centred", () => {
|
|
143
|
+
const { container } = render(
|
|
144
|
+
rtl(
|
|
145
|
+
<AlertDialog open>
|
|
146
|
+
<AlertDialogContent>
|
|
147
|
+
<AlertDialogHeader>
|
|
148
|
+
<AlertDialogTitle>Delete</AlertDialogTitle>
|
|
149
|
+
<AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
|
|
150
|
+
</AlertDialogHeader>
|
|
151
|
+
</AlertDialogContent>
|
|
152
|
+
</AlertDialog>,
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
const header = screen.getByText("Delete").parentElement!;
|
|
156
|
+
expect(header).toHaveClass("sm:text-start");
|
|
157
|
+
expect(header.className).not.toMatch(/sm:text-left/);
|
|
158
|
+
|
|
159
|
+
// The centring trick stays physical on purpose — it must not have been "converted" to start-1/2,
|
|
160
|
+
// which would read as mirroring while landing in the same place.
|
|
161
|
+
const content = screen.getByRole("alertdialog");
|
|
162
|
+
expect(content.className).toMatch(/\bleft-1\/2\b/);
|
|
163
|
+
expect(content.className).not.toMatch(/\bstart-1\/2\b/);
|
|
164
|
+
expect(container).toBeTruthy();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("spaces its footer buttons with a direction-agnostic gap", () => {
|
|
168
|
+
render(
|
|
169
|
+
rtl(
|
|
170
|
+
<AlertDialog open>
|
|
171
|
+
<AlertDialogContent>
|
|
172
|
+
<AlertDialogTitle>Delete</AlertDialogTitle>
|
|
173
|
+
<AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
|
|
174
|
+
<AlertDialogFooter>
|
|
175
|
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
176
|
+
<AlertDialogAction>Delete</AlertDialogAction>
|
|
177
|
+
</AlertDialogFooter>
|
|
178
|
+
</AlertDialogContent>
|
|
179
|
+
</AlertDialog>,
|
|
180
|
+
),
|
|
181
|
+
);
|
|
182
|
+
const footer = screen.getByRole("button", { name: "Cancel" }).parentElement!;
|
|
183
|
+
// `space-x-*` compiles to a physical margin-left, so under RTL it lands on the outside of the
|
|
184
|
+
// pair and the two buttons touch. `gap` has no side.
|
|
185
|
+
expect(footer).toHaveClass("gap-2");
|
|
186
|
+
expect(footer.className).not.toMatch(/space-x-/);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("NavigationMenu under RTL", () => {
|
|
191
|
+
it("anchors its panel and chevron gap on the inline axis", async () => {
|
|
192
|
+
const user = userEvent.setup();
|
|
193
|
+
render(
|
|
194
|
+
rtl(
|
|
195
|
+
<NavigationMenu>
|
|
196
|
+
<NavigationMenuList>
|
|
197
|
+
<NavigationMenuItem>
|
|
198
|
+
<NavigationMenuTrigger>Products</NavigationMenuTrigger>
|
|
199
|
+
<NavigationMenuContent>
|
|
200
|
+
<div>Panel</div>
|
|
201
|
+
</NavigationMenuContent>
|
|
202
|
+
</NavigationMenuItem>
|
|
203
|
+
</NavigationMenuList>
|
|
204
|
+
</NavigationMenu>,
|
|
205
|
+
),
|
|
206
|
+
);
|
|
207
|
+
const trigger = screen.getByRole("button", { name: /Products/ });
|
|
208
|
+
// The disclosure chevron is spaced from the label on the inline axis.
|
|
209
|
+
expect(trigger.querySelector(".ms-1")).not.toBeNull();
|
|
210
|
+
expect(trigger.querySelector(".ml-1")).toBeNull();
|
|
211
|
+
|
|
212
|
+
await user.click(trigger);
|
|
213
|
+
const panel = (await screen.findByText("Panel")).parentElement!;
|
|
214
|
+
expect(panel.className).toMatch(/\bstart-0\b/);
|
|
215
|
+
expect(panel.className).not.toMatch(/\bleft-0\b/);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Popover and Tooltip are the other half of the contract: their entry animation keys off `data-side`,
|
|
221
|
+
* which Radix has already resolved to a PHYSICAL side after flipping for direction and collisions. A
|
|
222
|
+
* well-meaning conversion to logical classes there would invert the animation under RTL, so these assert
|
|
223
|
+
* the physical classes are deliberately retained.
|
|
224
|
+
*/
|
|
225
|
+
describe("Popover and Tooltip keep their resolved-side animation physical", () => {
|
|
226
|
+
it("Popover slides away from the trigger on the side Radix resolved", async () => {
|
|
227
|
+
const user = userEvent.setup();
|
|
228
|
+
render(
|
|
229
|
+
rtl(
|
|
230
|
+
<Popover>
|
|
231
|
+
<PopoverTrigger>Open</PopoverTrigger>
|
|
232
|
+
<PopoverContent>Body</PopoverContent>
|
|
233
|
+
</Popover>,
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
await user.click(screen.getByRole("button", { name: "Open" }));
|
|
237
|
+
const content = await screen.findByText("Body");
|
|
238
|
+
expect(content.className).toMatch(/data-\[side=left\]:slide-in-from-right-2/);
|
|
239
|
+
expect(content.className).toMatch(/data-\[side=right\]:slide-in-from-left-2/);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("Tooltip does the same", async () => {
|
|
243
|
+
const user = userEvent.setup();
|
|
244
|
+
render(
|
|
245
|
+
rtl(
|
|
246
|
+
<TooltipProvider delayDuration={0}>
|
|
247
|
+
<Tooltip>
|
|
248
|
+
<TooltipTrigger>Hover</TooltipTrigger>
|
|
249
|
+
<TooltipContent>Hint</TooltipContent>
|
|
250
|
+
</Tooltip>
|
|
251
|
+
</TooltipProvider>,
|
|
252
|
+
),
|
|
253
|
+
);
|
|
254
|
+
await user.hover(screen.getByRole("button", { name: "Hover" }));
|
|
255
|
+
const tip = (await screen.findAllByText("Hint"))[0];
|
|
256
|
+
expect(tip.className).toMatch(/data-\[side=left\]:slide-in-from-right-2/);
|
|
257
|
+
});
|
|
258
|
+
});
|