@kinetixui/ui 0.23.3 → 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,482 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { KinetixDirectionProvider, useKinetixPortalContainer } from "./components/direction-provider";
|
|
6
|
+
import {
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogDescription,
|
|
10
|
+
DialogPortal,
|
|
11
|
+
DialogTitle,
|
|
12
|
+
DialogTrigger,
|
|
13
|
+
} from "./components/dialog";
|
|
14
|
+
import {
|
|
15
|
+
AlertDialog,
|
|
16
|
+
AlertDialogCancel,
|
|
17
|
+
AlertDialogContent,
|
|
18
|
+
AlertDialogDescription,
|
|
19
|
+
AlertDialogTitle,
|
|
20
|
+
AlertDialogTrigger,
|
|
21
|
+
} from "./components/alert-dialog";
|
|
22
|
+
import { Sheet, SheetContent, SheetDescription, SheetTitle, SheetTrigger } from "./components/sheet";
|
|
23
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./components/popover";
|
|
24
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./components/tooltip";
|
|
25
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./components/dropdown-menu";
|
|
26
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./components/select";
|
|
27
|
+
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "./components/context-menu";
|
|
28
|
+
import { Menubar, MenubarContent, MenubarItem, MenubarMenu, MenubarTrigger } from "./components/menubar";
|
|
29
|
+
import { Tour } from "./components/tour";
|
|
30
|
+
import { Drawer, DrawerContent, DrawerDescription, DrawerTitle, DrawerTrigger } from "./components/drawer";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* components-direction-portal.test.tsx — overlays portal with the direction of the section that opened them.
|
|
34
|
+
*
|
|
35
|
+
* A `KinetixDirectionProvider` around one section of a page (a component preview, an RTL panel inside an LTR
|
|
36
|
+
* app) used to direct only what rendered inside that section. Overlays portal to `<body>`, so they inherited
|
|
37
|
+
* `<html dir>` instead: an RTL preview opened an LTR Dialog. The documentation site papered over it by writing
|
|
38
|
+
* `<html dir>` from the preview's control, which mirrored the whole site (PR #300, §27).
|
|
39
|
+
*
|
|
40
|
+
* The contract here: inside a provider, every overlay's content sits under an element whose `dir` is the
|
|
41
|
+
* provider's, and `<html>` is never touched. Outside one, overlays still portal to `<body>` and inherit
|
|
42
|
+
* `<html dir>`, which is what applications that set direction globally rely on.
|
|
43
|
+
*
|
|
44
|
+
* jsdom does not compute inherited `direction`, so the assertion is on the nearest `[dir]` ancestor — the
|
|
45
|
+
* attribute CSS inheritance, `[dir]` selectors and Tailwind's `rtl:` variant all resolve from. Computed
|
|
46
|
+
* direction is checked in Chromium by the docs' preview verification.
|
|
47
|
+
*
|
|
48
|
+
* kx-verify: rtl
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
const dirOf = (el: Element) => el.closest("[dir]")?.getAttribute("dir") ?? null;
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
document.documentElement.removeAttribute("dir");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
function expectPageUntouched() {
|
|
58
|
+
expect(document.documentElement.hasAttribute("dir")).toBe(false);
|
|
59
|
+
expect(document.body.hasAttribute("dir")).toBe(false);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** An LTR page with one RTL section: the docs' preview, or an RTL panel in an LTR application. */
|
|
63
|
+
const rtlSection = (node: React.ReactNode) => (
|
|
64
|
+
<KinetixDirectionProvider dir="rtl">
|
|
65
|
+
<div dir="rtl">{node}</div>
|
|
66
|
+
</KinetixDirectionProvider>
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
describe("overlays opened inside a provider take its direction into their portal", () => {
|
|
70
|
+
// First: Radix's ContextMenu stops opening in a module once a modal overlay has mounted there.
|
|
71
|
+
it("ContextMenu", async () => {
|
|
72
|
+
render(
|
|
73
|
+
rtlSection(
|
|
74
|
+
<ContextMenu>
|
|
75
|
+
<ContextMenuTrigger>target</ContextMenuTrigger>
|
|
76
|
+
<ContextMenuContent>
|
|
77
|
+
<ContextMenuItem>Item</ContextMenuItem>
|
|
78
|
+
</ContextMenuContent>
|
|
79
|
+
</ContextMenu>,
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
fireEvent.contextMenu(screen.getByText("target"));
|
|
83
|
+
expect(dirOf(await screen.findByRole("menu"))).toBe("rtl");
|
|
84
|
+
expectPageUntouched();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("Dialog", async () => {
|
|
88
|
+
const user = userEvent.setup();
|
|
89
|
+
render(
|
|
90
|
+
rtlSection(
|
|
91
|
+
<Dialog>
|
|
92
|
+
<DialogTrigger>Open</DialogTrigger>
|
|
93
|
+
<DialogContent>
|
|
94
|
+
<DialogTitle>Title</DialogTitle>
|
|
95
|
+
<DialogDescription>Body</DialogDescription>
|
|
96
|
+
</DialogContent>
|
|
97
|
+
</Dialog>,
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
await user.click(screen.getByRole("button", { name: "Open" }));
|
|
101
|
+
const dialog = await screen.findByRole("dialog");
|
|
102
|
+
expect(dirOf(dialog)).toBe("rtl");
|
|
103
|
+
// It is still portaled — out of the section, into the provider's host under <body>.
|
|
104
|
+
expect(dialog.closest("[data-kinetix-portal]")?.parentElement).toBe(document.body);
|
|
105
|
+
expectPageUntouched();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("AlertDialog", async () => {
|
|
109
|
+
const user = userEvent.setup();
|
|
110
|
+
render(
|
|
111
|
+
rtlSection(
|
|
112
|
+
<AlertDialog>
|
|
113
|
+
<AlertDialogTrigger>Open</AlertDialogTrigger>
|
|
114
|
+
<AlertDialogContent>
|
|
115
|
+
<AlertDialogTitle>Title</AlertDialogTitle>
|
|
116
|
+
<AlertDialogDescription>Body</AlertDialogDescription>
|
|
117
|
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
118
|
+
</AlertDialogContent>
|
|
119
|
+
</AlertDialog>,
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
await user.click(screen.getByRole("button", { name: "Open" }));
|
|
123
|
+
expect(dirOf(await screen.findByRole("alertdialog"))).toBe("rtl");
|
|
124
|
+
expectPageUntouched();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("Sheet", async () => {
|
|
128
|
+
const user = userEvent.setup();
|
|
129
|
+
render(
|
|
130
|
+
rtlSection(
|
|
131
|
+
<Sheet>
|
|
132
|
+
<SheetTrigger>Open</SheetTrigger>
|
|
133
|
+
<SheetContent>
|
|
134
|
+
<SheetTitle>Title</SheetTitle>
|
|
135
|
+
<SheetDescription>Body</SheetDescription>
|
|
136
|
+
</SheetContent>
|
|
137
|
+
</Sheet>,
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
await user.click(screen.getByRole("button", { name: "Open" }));
|
|
141
|
+
expect(dirOf(await screen.findByRole("dialog"))).toBe("rtl");
|
|
142
|
+
expectPageUntouched();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("Popover", async () => {
|
|
146
|
+
const user = userEvent.setup();
|
|
147
|
+
render(
|
|
148
|
+
rtlSection(
|
|
149
|
+
<Popover>
|
|
150
|
+
<PopoverTrigger>Open</PopoverTrigger>
|
|
151
|
+
<PopoverContent>Body</PopoverContent>
|
|
152
|
+
</Popover>,
|
|
153
|
+
),
|
|
154
|
+
);
|
|
155
|
+
await user.click(screen.getByRole("button", { name: "Open" }));
|
|
156
|
+
expect(dirOf(await screen.findByText("Body"))).toBe("rtl");
|
|
157
|
+
expectPageUntouched();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("Tooltip", async () => {
|
|
161
|
+
render(
|
|
162
|
+
rtlSection(
|
|
163
|
+
<TooltipProvider delayDuration={0}>
|
|
164
|
+
<Tooltip open>
|
|
165
|
+
<TooltipTrigger>Hover</TooltipTrigger>
|
|
166
|
+
<TooltipContent>Tip</TooltipContent>
|
|
167
|
+
</Tooltip>
|
|
168
|
+
</TooltipProvider>,
|
|
169
|
+
),
|
|
170
|
+
);
|
|
171
|
+
expect(dirOf(await screen.findByRole("tooltip"))).toBe("rtl");
|
|
172
|
+
expectPageUntouched();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// These three stamp `dir` on their own content from Radix's DirectionContext, which the provider already
|
|
176
|
+
// supplied. Asserted so moving their portal does not change what they did.
|
|
177
|
+
it("DropdownMenu keeps the direction it already had", async () => {
|
|
178
|
+
const user = userEvent.setup();
|
|
179
|
+
render(
|
|
180
|
+
rtlSection(
|
|
181
|
+
<DropdownMenu>
|
|
182
|
+
<DropdownMenuTrigger>Open</DropdownMenuTrigger>
|
|
183
|
+
<DropdownMenuContent>
|
|
184
|
+
<DropdownMenuItem>Item</DropdownMenuItem>
|
|
185
|
+
</DropdownMenuContent>
|
|
186
|
+
</DropdownMenu>,
|
|
187
|
+
),
|
|
188
|
+
);
|
|
189
|
+
screen.getByRole("button", { name: "Open" }).focus();
|
|
190
|
+
await user.keyboard("{Enter}");
|
|
191
|
+
expect(dirOf(await screen.findByRole("menu"))).toBe("rtl");
|
|
192
|
+
expectPageUntouched();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("Select keeps the direction it already had", async () => {
|
|
196
|
+
const user = userEvent.setup();
|
|
197
|
+
render(
|
|
198
|
+
rtlSection(
|
|
199
|
+
<Select>
|
|
200
|
+
<SelectTrigger aria-label="Fruit">
|
|
201
|
+
<SelectValue placeholder="Pick" />
|
|
202
|
+
</SelectTrigger>
|
|
203
|
+
<SelectContent>
|
|
204
|
+
<SelectItem value="a">Apple</SelectItem>
|
|
205
|
+
</SelectContent>
|
|
206
|
+
</Select>,
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
// Radix's Select does not open from a synthetic click in jsdom; Enter reaches the same path.
|
|
210
|
+
screen.getByRole("combobox", { name: "Fruit" }).focus();
|
|
211
|
+
await user.keyboard("{Enter}");
|
|
212
|
+
expect(dirOf(await screen.findByRole("listbox"))).toBe("rtl");
|
|
213
|
+
expectPageUntouched();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("Menubar keeps the direction it already had", async () => {
|
|
217
|
+
const user = userEvent.setup();
|
|
218
|
+
render(
|
|
219
|
+
rtlSection(
|
|
220
|
+
<Menubar>
|
|
221
|
+
<MenubarMenu>
|
|
222
|
+
<MenubarTrigger>File</MenubarTrigger>
|
|
223
|
+
<MenubarContent>
|
|
224
|
+
<MenubarItem>New</MenubarItem>
|
|
225
|
+
</MenubarContent>
|
|
226
|
+
</MenubarMenu>
|
|
227
|
+
</Menubar>,
|
|
228
|
+
),
|
|
229
|
+
);
|
|
230
|
+
screen.getByRole("menuitem", { name: "File" }).focus();
|
|
231
|
+
await user.keyboard("{Enter}");
|
|
232
|
+
expect(dirOf(await screen.findByRole("menu"))).toBe("rtl");
|
|
233
|
+
expectPageUntouched();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("Tour", async () => {
|
|
237
|
+
render(
|
|
238
|
+
rtlSection(
|
|
239
|
+
<>
|
|
240
|
+
<span id="tour-target">here</span>
|
|
241
|
+
<Tour
|
|
242
|
+
open
|
|
243
|
+
stepIndex={0}
|
|
244
|
+
onStepIndexChange={() => {}}
|
|
245
|
+
onOpenChange={() => {}}
|
|
246
|
+
steps={[{ target: "#tour-target", title: "Step", content: "Body" }]}
|
|
247
|
+
/>
|
|
248
|
+
</>,
|
|
249
|
+
),
|
|
250
|
+
);
|
|
251
|
+
const dialog = await screen.findByRole("dialog");
|
|
252
|
+
expect(dirOf(dialog)).toBe("rtl");
|
|
253
|
+
expect(dialog.closest("[data-kinetix-portal]")).not.toBeNull();
|
|
254
|
+
expectPageUntouched();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("direction boundaries", () => {
|
|
259
|
+
function DialogExample({ label }: { label: string }) {
|
|
260
|
+
return (
|
|
261
|
+
<Dialog>
|
|
262
|
+
<DialogTrigger>{label}</DialogTrigger>
|
|
263
|
+
<DialogContent>
|
|
264
|
+
<DialogTitle>{`${label} title`}</DialogTitle>
|
|
265
|
+
<DialogDescription>Body</DialogDescription>
|
|
266
|
+
</DialogContent>
|
|
267
|
+
</Dialog>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
it("two sections on one page keep their own direction", async () => {
|
|
272
|
+
const user = userEvent.setup();
|
|
273
|
+
render(
|
|
274
|
+
<>
|
|
275
|
+
<KinetixDirectionProvider dir="rtl">
|
|
276
|
+
<DialogExample label="Right" />
|
|
277
|
+
</KinetixDirectionProvider>
|
|
278
|
+
<KinetixDirectionProvider dir="ltr">
|
|
279
|
+
<DialogExample label="Left" />
|
|
280
|
+
</KinetixDirectionProvider>
|
|
281
|
+
</>,
|
|
282
|
+
);
|
|
283
|
+
await user.click(screen.getByRole("button", { name: "Left" }));
|
|
284
|
+
expect(dirOf(await screen.findByRole("dialog", { name: "Left title" }))).toBe("ltr");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("the nearest provider wins when sections nest", async () => {
|
|
288
|
+
const user = userEvent.setup();
|
|
289
|
+
render(
|
|
290
|
+
<KinetixDirectionProvider dir="rtl">
|
|
291
|
+
<KinetixDirectionProvider dir="ltr">
|
|
292
|
+
<DialogExample label="Inner" />
|
|
293
|
+
</KinetixDirectionProvider>
|
|
294
|
+
</KinetixDirectionProvider>,
|
|
295
|
+
);
|
|
296
|
+
await user.click(screen.getByRole("button", { name: "Inner" }));
|
|
297
|
+
expect(dirOf(await screen.findByRole("dialog"))).toBe("ltr");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("an LTR section inside an RTL application stays LTR", async () => {
|
|
301
|
+
document.documentElement.setAttribute("dir", "rtl");
|
|
302
|
+
const user = userEvent.setup();
|
|
303
|
+
render(
|
|
304
|
+
<KinetixDirectionProvider dir="ltr">
|
|
305
|
+
<DialogExample label="Open" />
|
|
306
|
+
</KinetixDirectionProvider>,
|
|
307
|
+
);
|
|
308
|
+
await user.click(screen.getByRole("button", { name: "Open" }));
|
|
309
|
+
expect(dirOf(await screen.findByRole("dialog"))).toBe("ltr");
|
|
310
|
+
expect(document.documentElement.getAttribute("dir")).toBe("rtl");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("follows the provider when its direction changes", async () => {
|
|
314
|
+
function FlippingSection() {
|
|
315
|
+
const [dir, setDir] = React.useState<"ltr" | "rtl">("ltr");
|
|
316
|
+
return (
|
|
317
|
+
<KinetixDirectionProvider dir={dir}>
|
|
318
|
+
<button onClick={() => setDir("rtl")}>flip</button>
|
|
319
|
+
<Popover open>
|
|
320
|
+
<PopoverTrigger>Anchor</PopoverTrigger>
|
|
321
|
+
<PopoverContent>Body</PopoverContent>
|
|
322
|
+
</Popover>
|
|
323
|
+
</KinetixDirectionProvider>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
render(<FlippingSection />);
|
|
327
|
+
const body = await screen.findByText("Body");
|
|
328
|
+
expect(dirOf(body)).toBe("ltr");
|
|
329
|
+
act(() => void fireEvent.click(screen.getByRole("button", { name: "flip" })));
|
|
330
|
+
expect(dirOf(body)).toBe("rtl");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("an explicit portal container still wins", () => {
|
|
334
|
+
const own = document.createElement("div");
|
|
335
|
+
own.setAttribute("dir", "ltr");
|
|
336
|
+
document.body.appendChild(own);
|
|
337
|
+
render(
|
|
338
|
+
<KinetixDirectionProvider dir="rtl">
|
|
339
|
+
<Dialog open>
|
|
340
|
+
<DialogPortal container={own}>
|
|
341
|
+
<p>Body</p>
|
|
342
|
+
</DialogPortal>
|
|
343
|
+
</Dialog>
|
|
344
|
+
</KinetixDirectionProvider>,
|
|
345
|
+
);
|
|
346
|
+
expect(own.contains(screen.getByText("Body"))).toBe(true);
|
|
347
|
+
expect(dirOf(screen.getByText("Body"))).toBe("ltr");
|
|
348
|
+
own.remove();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("never commits portal content into a host that is not attached yet", () => {
|
|
352
|
+
// A provider and an already-open overlay mounting in the same commit: descendants' layout effects run
|
|
353
|
+
// before the provider's, so a host published too early would receive the portal's DOM while detached.
|
|
354
|
+
const connected: boolean[] = [];
|
|
355
|
+
function Probe() {
|
|
356
|
+
const ref = React.useRef<HTMLSpanElement>(null);
|
|
357
|
+
React.useLayoutEffect(() => {
|
|
358
|
+
connected.push(!!ref.current?.isConnected);
|
|
359
|
+
}, []);
|
|
360
|
+
return <span ref={ref}>probe</span>;
|
|
361
|
+
}
|
|
362
|
+
render(
|
|
363
|
+
<KinetixDirectionProvider dir="rtl">
|
|
364
|
+
<Popover open>
|
|
365
|
+
<PopoverTrigger>Anchor</PopoverTrigger>
|
|
366
|
+
<PopoverContent>
|
|
367
|
+
<Probe />
|
|
368
|
+
</PopoverContent>
|
|
369
|
+
</Popover>
|
|
370
|
+
</KinetixDirectionProvider>,
|
|
371
|
+
);
|
|
372
|
+
expect(connected).toEqual([true]);
|
|
373
|
+
expect(dirOf(screen.getByText("probe"))).toBe("rtl");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("exposes the host to custom portals, and removes it when the provider unmounts", () => {
|
|
377
|
+
let seen: HTMLElement | undefined;
|
|
378
|
+
function Probe() {
|
|
379
|
+
seen = useKinetixPortalContainer();
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
const view = render(
|
|
383
|
+
<KinetixDirectionProvider dir="rtl">
|
|
384
|
+
<Probe />
|
|
385
|
+
</KinetixDirectionProvider>,
|
|
386
|
+
);
|
|
387
|
+
expect(seen?.getAttribute("dir")).toBe("rtl");
|
|
388
|
+
expect(seen?.parentElement).toBe(document.body);
|
|
389
|
+
view.unmount();
|
|
390
|
+
expect(seen?.isConnected).toBe(false);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe("without a provider (direction set globally on <html>)", () => {
|
|
395
|
+
it("overlays still portal to <body> and inherit <html dir>", async () => {
|
|
396
|
+
document.documentElement.setAttribute("dir", "rtl");
|
|
397
|
+
const user = userEvent.setup();
|
|
398
|
+
render(
|
|
399
|
+
<Dialog>
|
|
400
|
+
<DialogTrigger>Open</DialogTrigger>
|
|
401
|
+
<DialogContent>
|
|
402
|
+
<DialogTitle>Title</DialogTitle>
|
|
403
|
+
<DialogDescription>Body</DialogDescription>
|
|
404
|
+
</DialogContent>
|
|
405
|
+
</Dialog>,
|
|
406
|
+
);
|
|
407
|
+
await user.click(screen.getByRole("button", { name: "Open" }));
|
|
408
|
+
const dialog = await screen.findByRole("dialog");
|
|
409
|
+
expect(dialog.closest("[data-kinetix-portal]")).toBeNull();
|
|
410
|
+
expect(dirOf(dialog)).toBe("rtl");
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe("focus and reading order inside a directional portal", () => {
|
|
415
|
+
it("Dialog traps focus in its content and returns it to the trigger on close", async () => {
|
|
416
|
+
const user = userEvent.setup();
|
|
417
|
+
render(
|
|
418
|
+
rtlSection(
|
|
419
|
+
<Dialog>
|
|
420
|
+
<DialogTrigger>Open</DialogTrigger>
|
|
421
|
+
<DialogContent>
|
|
422
|
+
<DialogTitle>Title</DialogTitle>
|
|
423
|
+
<DialogDescription>Body</DialogDescription>
|
|
424
|
+
<button>First</button>
|
|
425
|
+
</DialogContent>
|
|
426
|
+
</Dialog>,
|
|
427
|
+
),
|
|
428
|
+
);
|
|
429
|
+
const trigger = screen.getByRole("button", { name: "Open" });
|
|
430
|
+
await user.click(trigger);
|
|
431
|
+
const dialog = await screen.findByRole("dialog");
|
|
432
|
+
expect(dialog.contains(document.activeElement)).toBe(true);
|
|
433
|
+
await user.keyboard("{Escape}");
|
|
434
|
+
expect(screen.queryByRole("dialog")).toBeNull();
|
|
435
|
+
expect(document.activeElement).toBe(trigger);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("a modal Dialog hides the rest of the page from assistive tech, but not itself", async () => {
|
|
439
|
+
const user = userEvent.setup();
|
|
440
|
+
render(
|
|
441
|
+
<>
|
|
442
|
+
<p>page text</p>
|
|
443
|
+
{rtlSection(
|
|
444
|
+
<Dialog>
|
|
445
|
+
<DialogTrigger>Open</DialogTrigger>
|
|
446
|
+
<DialogContent>
|
|
447
|
+
<DialogTitle>Title</DialogTitle>
|
|
448
|
+
<DialogDescription>Body</DialogDescription>
|
|
449
|
+
</DialogContent>
|
|
450
|
+
</Dialog>,
|
|
451
|
+
)}
|
|
452
|
+
</>,
|
|
453
|
+
);
|
|
454
|
+
await user.click(screen.getByRole("button", { name: "Open" }));
|
|
455
|
+
const dialog = await screen.findByRole("dialog");
|
|
456
|
+
// The host is the dialog's ancestor, so Radix's aria-hidden sweep must skip it.
|
|
457
|
+
expect(dialog.closest("[aria-hidden=true]")).toBeNull();
|
|
458
|
+
expect(screen.getByText("page text").closest("[aria-hidden=true]")).not.toBeNull();
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// Last in the file: once vaul's modal Drawer has mounted, Radix's ContextMenu no longer opens in this
|
|
463
|
+
// module (see components-overlay.test.tsx).
|
|
464
|
+
describe("Drawer", () => {
|
|
465
|
+
it("portals with the provider's direction", async () => {
|
|
466
|
+
const user = userEvent.setup();
|
|
467
|
+
render(
|
|
468
|
+
rtlSection(
|
|
469
|
+
<Drawer>
|
|
470
|
+
<DrawerTrigger>Open</DrawerTrigger>
|
|
471
|
+
<DrawerContent>
|
|
472
|
+
<DrawerTitle>Title</DrawerTitle>
|
|
473
|
+
<DrawerDescription>Body</DrawerDescription>
|
|
474
|
+
</DrawerContent>
|
|
475
|
+
</Drawer>,
|
|
476
|
+
),
|
|
477
|
+
);
|
|
478
|
+
await user.click(screen.getByRole("button", { name: "Open" }));
|
|
479
|
+
expect(dirOf(await screen.findByRole("dialog"))).toBe("rtl");
|
|
480
|
+
expectPageUntouched();
|
|
481
|
+
});
|
|
482
|
+
});
|