@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,435 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { useForm } from "react-hook-form";
|
|
6
|
+
import { KinetixDirectionProvider } from "./components/direction-provider";
|
|
7
|
+
import { Input } from "./components/input";
|
|
8
|
+
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./components/input-otp";
|
|
9
|
+
import { Label } from "./components/label";
|
|
10
|
+
import { MultiSelect } from "./components/multi-select";
|
|
11
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./components/select";
|
|
12
|
+
import { Slider } from "./components/slider";
|
|
13
|
+
import { Textarea } from "./components/textarea";
|
|
14
|
+
import {
|
|
15
|
+
Form,
|
|
16
|
+
FormControl,
|
|
17
|
+
FormDescription,
|
|
18
|
+
FormField,
|
|
19
|
+
FormItem,
|
|
20
|
+
FormLabel,
|
|
21
|
+
FormMessage,
|
|
22
|
+
} from "./components/form";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* components-form.test.tsx — the form family's state contract and its behaviour under RTL.
|
|
26
|
+
*
|
|
27
|
+
* The keyboard half lives in `components-keyboard.test.tsx`, because that file is what
|
|
28
|
+
* `scripts/gen-keyboard.mjs` turns into each component's published keyboard model. What is here is
|
|
29
|
+
* everything else a form control has to get right and nothing was asserting: which of `value` and
|
|
30
|
+
* `defaultValue` owns the state, that a handler fires once with the requested value, that disabled and
|
|
31
|
+
* read-only differ in the way they are supposed to differ, and that an invalid field is wired to the
|
|
32
|
+
* message that explains it.
|
|
33
|
+
*
|
|
34
|
+
* jsdom has no layout, so direction assertions here are about the contract — the rendered class is the
|
|
35
|
+
* logical one, and direction-dependent key handling mirrors. Geometry is the browser pass's job:
|
|
36
|
+
* `scripts/large-text.mjs` measures these controls, and the InputOTP mirror below was found by measuring
|
|
37
|
+
* it rather than by reading it.
|
|
38
|
+
*
|
|
39
|
+
* kx-verify: interaction
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
beforeAll(() => {
|
|
43
|
+
// `input-otp` polls document.elementFromPoint to track the caret's slot; jsdom has no such method, and
|
|
44
|
+
// unstubbed it throws from a timer after the test that triggered it has already passed.
|
|
45
|
+
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () => null;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const OPTIONS = [
|
|
49
|
+
{ value: "apple", label: "Apple" },
|
|
50
|
+
{ value: "banana", label: "Banana" },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
describe("Input and Textarea state", () => {
|
|
54
|
+
it("keep their own value when uncontrolled, and restore it on form reset", async () => {
|
|
55
|
+
const user = userEvent.setup();
|
|
56
|
+
render(
|
|
57
|
+
<form>
|
|
58
|
+
<Input aria-label="Nickname" defaultValue="start" />
|
|
59
|
+
<button type="reset">Reset</button>
|
|
60
|
+
</form>,
|
|
61
|
+
);
|
|
62
|
+
const field = screen.getByLabelText("Nickname") as HTMLInputElement;
|
|
63
|
+
await user.clear(field);
|
|
64
|
+
await user.type(field, "typed");
|
|
65
|
+
expect(field.value).toBe("typed");
|
|
66
|
+
|
|
67
|
+
// `defaultValue` is the form's reset value, which is the whole reason to prefer it over state here.
|
|
68
|
+
await user.click(screen.getByRole("button", { name: "Reset" }));
|
|
69
|
+
expect(field.value).toBe("start");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("stay controlled when given a value, and take an external update", async () => {
|
|
73
|
+
const user = userEvent.setup();
|
|
74
|
+
const onChange = vi.fn();
|
|
75
|
+
function Controlled() {
|
|
76
|
+
const [value, setValue] = React.useState("a");
|
|
77
|
+
return (
|
|
78
|
+
<>
|
|
79
|
+
<Input
|
|
80
|
+
aria-label="Code"
|
|
81
|
+
value={value}
|
|
82
|
+
onChange={(e) => {
|
|
83
|
+
onChange(e.target.value);
|
|
84
|
+
setValue(e.target.value);
|
|
85
|
+
}}
|
|
86
|
+
/>
|
|
87
|
+
<button onClick={() => setValue("from outside")}>Set</button>
|
|
88
|
+
</>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
render(<Controlled />);
|
|
92
|
+
const field = screen.getByLabelText("Code") as HTMLInputElement;
|
|
93
|
+
await user.type(field, "b");
|
|
94
|
+
expect(onChange).toHaveBeenCalledExactlyOnceWith("ab");
|
|
95
|
+
expect(field.value).toBe("ab");
|
|
96
|
+
|
|
97
|
+
await user.click(screen.getByRole("button", { name: "Set" }));
|
|
98
|
+
expect(field.value).toBe("from outside");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("distinguish disabled from read-only: one is out of reach, the other is readable but not editable", async () => {
|
|
102
|
+
const user = userEvent.setup();
|
|
103
|
+
render(
|
|
104
|
+
<>
|
|
105
|
+
<Input aria-label="Disabled" disabled defaultValue="d" />
|
|
106
|
+
<Textarea aria-label="Read-only" readOnly defaultValue="r" />
|
|
107
|
+
</>,
|
|
108
|
+
);
|
|
109
|
+
const disabled = screen.getByLabelText("Disabled") as HTMLInputElement;
|
|
110
|
+
const readOnly = screen.getByLabelText("Read-only") as HTMLTextAreaElement;
|
|
111
|
+
expect(disabled).toBeDisabled();
|
|
112
|
+
expect(readOnly).not.toBeDisabled();
|
|
113
|
+
expect(readOnly).toHaveAttribute("readonly");
|
|
114
|
+
|
|
115
|
+
await user.type(disabled, "x");
|
|
116
|
+
await user.type(readOnly, "x");
|
|
117
|
+
expect(disabled.value).toBe("d");
|
|
118
|
+
expect(readOnly.value).toBe("r");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("marks itself invalid from the design-source Error state, and not otherwise", () => {
|
|
122
|
+
const { rerender } = render(<Input aria-label="Email" />);
|
|
123
|
+
expect(screen.getByLabelText("Email")).not.toHaveAttribute("aria-invalid", "true");
|
|
124
|
+
rerender(<Input aria-label="Email" state="Error" />);
|
|
125
|
+
expect(screen.getByLabelText("Email")).toHaveAttribute("aria-invalid", "true");
|
|
126
|
+
// An explicit prop still wins: `state` is a design-source variant, not an override of the consumer.
|
|
127
|
+
rerender(<Input aria-label="Email" state="Error" aria-invalid={false} />);
|
|
128
|
+
expect(screen.getByLabelText("Email")).toHaveAttribute("aria-invalid", "false");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("pins the native disabled attribute when the design-source state says Disabled", () => {
|
|
132
|
+
render(<Input aria-label="Pinned" state="Disabled" />);
|
|
133
|
+
// Not just an opacity class: a field that only looks disabled is still typeable.
|
|
134
|
+
expect(screen.getByLabelText("Pinned")).toBeDisabled();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("InputOTP layout", () => {
|
|
139
|
+
it("lets its slots wrap rather than push the page sideways", () => {
|
|
140
|
+
render(
|
|
141
|
+
<InputOTP maxLength={6} aria-label="One-time code">
|
|
142
|
+
<InputOTPGroup data-testid="group">
|
|
143
|
+
{[0, 1, 2, 3, 4, 5].map((i) => (
|
|
144
|
+
<InputOTPSlot key={i} index={i} />
|
|
145
|
+
))}
|
|
146
|
+
</InputOTPGroup>
|
|
147
|
+
</InputOTP>,
|
|
148
|
+
);
|
|
149
|
+
// Measured at 390px with the root font size doubled: six 72px slots made a 432px row and the document
|
|
150
|
+
// gained 106px of horizontal scrolling. jsdom cannot see that; what it can hold is the class that
|
|
151
|
+
// fixes it, so the geometry check in scripts/large-text.mjs and this assertion fail together.
|
|
152
|
+
expect(screen.getByTestId("group")).toHaveClass("flex-wrap");
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("Label", () => {
|
|
157
|
+
it("names the control it points at, and the one it wraps", () => {
|
|
158
|
+
render(
|
|
159
|
+
<>
|
|
160
|
+
<Label htmlFor="explicit">Explicit</Label>
|
|
161
|
+
<Input id="explicit" />
|
|
162
|
+
<Label>
|
|
163
|
+
Wrapping
|
|
164
|
+
<Input />
|
|
165
|
+
</Label>
|
|
166
|
+
</>,
|
|
167
|
+
);
|
|
168
|
+
expect(screen.getByLabelText("Explicit")).toHaveAttribute("id", "explicit");
|
|
169
|
+
expect(screen.getByLabelText("Wrapping")).toBeInstanceOf(HTMLInputElement);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("Form validation", () => {
|
|
174
|
+
function Example() {
|
|
175
|
+
const form = useForm({ defaultValues: { email: "" } });
|
|
176
|
+
return (
|
|
177
|
+
<Form {...form}>
|
|
178
|
+
<form onSubmit={form.handleSubmit(() => {})}>
|
|
179
|
+
<FormField
|
|
180
|
+
name="email"
|
|
181
|
+
rules={{ required: "Email is required" }}
|
|
182
|
+
render={({ field }) => (
|
|
183
|
+
<FormItem>
|
|
184
|
+
<FormLabel>Email</FormLabel>
|
|
185
|
+
<FormControl>
|
|
186
|
+
<Input {...field} />
|
|
187
|
+
</FormControl>
|
|
188
|
+
<FormDescription>We never share it.</FormDescription>
|
|
189
|
+
<FormMessage />
|
|
190
|
+
</FormItem>
|
|
191
|
+
)}
|
|
192
|
+
/>
|
|
193
|
+
<button type="submit">Save</button>
|
|
194
|
+
</form>
|
|
195
|
+
</Form>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
it("associates the label, the description and — once it fails — the message with the control", async () => {
|
|
200
|
+
const user = userEvent.setup();
|
|
201
|
+
render(<Example />);
|
|
202
|
+
const field = screen.getByLabelText("Email");
|
|
203
|
+
expect(field).toHaveAttribute("aria-invalid", "false");
|
|
204
|
+
const describedBefore = field.getAttribute("aria-describedby") ?? "";
|
|
205
|
+
expect(describedBefore).toBe(screen.getByText("We never share it.").id);
|
|
206
|
+
|
|
207
|
+
await user.click(screen.getByRole("button", { name: "Save" }));
|
|
208
|
+
const message = await screen.findByText("Email is required");
|
|
209
|
+
expect(field).toHaveAttribute("aria-invalid", "true");
|
|
210
|
+
// Both, in order: the help text does not disappear because an error arrived.
|
|
211
|
+
expect(field.getAttribute("aria-describedby")?.split(" ")).toEqual([describedBefore, message.id]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("moves focus to the failing control, which is what makes the message heard", async () => {
|
|
215
|
+
const user = userEvent.setup();
|
|
216
|
+
render(<Example />);
|
|
217
|
+
await user.click(screen.getByRole("button", { name: "Save" }));
|
|
218
|
+
await screen.findByText("Email is required");
|
|
219
|
+
// There is no live region here, and deliberately so: react-hook-form focuses the first invalid
|
|
220
|
+
// field, and a focused control is announced with its description — which now carries the message.
|
|
221
|
+
// A `role="alert"` on top of that would read the same sentence twice.
|
|
222
|
+
expect(screen.getByLabelText("Email")).toHaveFocus();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("renders no message element at all while the field is valid", () => {
|
|
226
|
+
render(<Example />);
|
|
227
|
+
expect(screen.queryByText("Email is required")).toBeNull();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("Select state", () => {
|
|
232
|
+
it("stays on the controlled value until the consumer changes it, reporting the request once", async () => {
|
|
233
|
+
const user = userEvent.setup();
|
|
234
|
+
const onValueChange = vi.fn();
|
|
235
|
+
render(
|
|
236
|
+
<Select value="apple" onValueChange={onValueChange}>
|
|
237
|
+
<SelectTrigger aria-label="Fruit">
|
|
238
|
+
<SelectValue />
|
|
239
|
+
</SelectTrigger>
|
|
240
|
+
<SelectContent>
|
|
241
|
+
{OPTIONS.map((o) => (
|
|
242
|
+
<SelectItem key={o.value} value={o.value}>
|
|
243
|
+
{o.label}
|
|
244
|
+
</SelectItem>
|
|
245
|
+
))}
|
|
246
|
+
</SelectContent>
|
|
247
|
+
</Select>,
|
|
248
|
+
);
|
|
249
|
+
// Opened from the keyboard: Radix's Select tracks pointer type and does not open from a synthetic
|
|
250
|
+
// click in jsdom. Enter reaches the same open/select code path.
|
|
251
|
+
await user.tab();
|
|
252
|
+
await user.keyboard("{Enter}");
|
|
253
|
+
await screen.findByRole("listbox");
|
|
254
|
+
await user.keyboard("{ArrowDown}{Enter}");
|
|
255
|
+
expect(onValueChange).toHaveBeenCalledExactlyOnceWith("banana");
|
|
256
|
+
// Controlled means controlled: the trigger must not move on its own.
|
|
257
|
+
expect(screen.getByRole("combobox", { name: "Fruit" })).toHaveTextContent("Apple");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("keeps its own selection when uncontrolled", async () => {
|
|
261
|
+
const user = userEvent.setup();
|
|
262
|
+
render(
|
|
263
|
+
<Select defaultValue="apple">
|
|
264
|
+
<SelectTrigger aria-label="Fruit">
|
|
265
|
+
<SelectValue />
|
|
266
|
+
</SelectTrigger>
|
|
267
|
+
<SelectContent>
|
|
268
|
+
{OPTIONS.map((o) => (
|
|
269
|
+
<SelectItem key={o.value} value={o.value}>
|
|
270
|
+
{o.label}
|
|
271
|
+
</SelectItem>
|
|
272
|
+
))}
|
|
273
|
+
</SelectContent>
|
|
274
|
+
</Select>,
|
|
275
|
+
);
|
|
276
|
+
await user.tab();
|
|
277
|
+
await user.keyboard("{Enter}");
|
|
278
|
+
await screen.findByRole("listbox");
|
|
279
|
+
await user.keyboard("{ArrowDown}{Enter}");
|
|
280
|
+
await waitFor(() => expect(screen.getByRole("combobox", { name: "Fruit" })).toHaveTextContent("Banana"));
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("MultiSelect state", () => {
|
|
285
|
+
it("shows only the controlled value, and asks for the full next selection", async () => {
|
|
286
|
+
const user = userEvent.setup();
|
|
287
|
+
const onValueChange = vi.fn();
|
|
288
|
+
render(<MultiSelect aria-label="Fruit" options={OPTIONS} value={["apple"]} onValueChange={onValueChange} />);
|
|
289
|
+
const combobox = screen.getByRole("combobox", { name: "Fruit" });
|
|
290
|
+
expect(within(combobox).getByText("Apple")).toBeInTheDocument();
|
|
291
|
+
|
|
292
|
+
await user.click(combobox);
|
|
293
|
+
await user.click(await screen.findByRole("option", { name: "Banana" }));
|
|
294
|
+
// The whole array, not a delta — and once.
|
|
295
|
+
expect(onValueChange).toHaveBeenCalledExactlyOnceWith(["apple", "banana"]);
|
|
296
|
+
expect(within(screen.getByRole("combobox", { name: "Fruit" })).queryByText("Banana")).toBeNull();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("adds and removes its own chips when uncontrolled", async () => {
|
|
300
|
+
const user = userEvent.setup();
|
|
301
|
+
render(<MultiSelect aria-label="Fruit" options={OPTIONS} defaultValue={["apple"]} />);
|
|
302
|
+
const chips = () => within(screen.getByRole("combobox", { name: "Fruit" }));
|
|
303
|
+
expect(chips().getByText("Apple")).toBeInTheDocument();
|
|
304
|
+
|
|
305
|
+
await user.click(chips().getByRole("button", { name: "Remove Apple" }));
|
|
306
|
+
expect(chips().queryByText("Apple")).toBeNull();
|
|
307
|
+
expect(screen.getByText("Select…")).toBeInTheDocument();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// kx-verify: interaction, rtl
|
|
312
|
+
|
|
313
|
+
describe("The form family under RTL", () => {
|
|
314
|
+
const rtl = (node: React.ReactNode) => <KinetixDirectionProvider dir="rtl">{node}</KinetixDirectionProvider>;
|
|
315
|
+
|
|
316
|
+
it("moves <Slider> with the arrow that points the way the track now runs", async () => {
|
|
317
|
+
const user = userEvent.setup();
|
|
318
|
+
const onValueChange = vi.fn();
|
|
319
|
+
render(rtl(<Slider aria-label="Volume" defaultValue={[50]} max={100} onValueChange={onValueChange} />));
|
|
320
|
+
await user.tab();
|
|
321
|
+
expect(screen.getByRole("slider", { name: "Volume" })).toHaveFocus();
|
|
322
|
+
|
|
323
|
+
// Under RTL the track's high end is on the left, so Arrow right must *decrease* — the arrow follows
|
|
324
|
+
// what the user sees, not the axis. This is Radix reading KinetixDirectionProvider: it is a React
|
|
325
|
+
// context, so a `dir` attribute in the DOM alone would mirror the pixels and not the keys.
|
|
326
|
+
await user.keyboard("{ArrowRight}");
|
|
327
|
+
expect(onValueChange).toHaveBeenLastCalledWith([49]);
|
|
328
|
+
await user.keyboard("{ArrowLeft}");
|
|
329
|
+
expect(onValueChange).toHaveBeenLastCalledWith([50]);
|
|
330
|
+
|
|
331
|
+
// Home and End name the ends of the range, not the sides of the screen, so they do not mirror.
|
|
332
|
+
await user.keyboard("{Home}");
|
|
333
|
+
expect(onValueChange).toHaveBeenLastCalledWith([0]);
|
|
334
|
+
await user.keyboard("{End}");
|
|
335
|
+
expect(onValueChange).toHaveBeenLastCalledWith([100]);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("gives <InputOTP> slots logical borders, so the group's ends are its ends in either direction", () => {
|
|
339
|
+
render(
|
|
340
|
+
rtl(
|
|
341
|
+
<InputOTP maxLength={3} aria-label="One-time code">
|
|
342
|
+
<InputOTPGroup>
|
|
343
|
+
{[0, 1, 2].map((i) => (
|
|
344
|
+
<InputOTPSlot key={i} index={i} data-testid={`slot-${i}`} />
|
|
345
|
+
))}
|
|
346
|
+
</InputOTPGroup>
|
|
347
|
+
</InputOTP>,
|
|
348
|
+
),
|
|
349
|
+
);
|
|
350
|
+
const slot = screen.getByTestId("slot-0");
|
|
351
|
+
// The slots are a flex row, so RTL reverses them and the first slot moves to the right-hand end.
|
|
352
|
+
// With a physical divider and a physical radius the rounded corners and the open edge stayed on the
|
|
353
|
+
// left: measured in Chromium, both radii landed on the group's *inner* edges, the divider after the
|
|
354
|
+
// first slot doubled to 2px, and the left-hand outer edge had no border at all.
|
|
355
|
+
//
|
|
356
|
+
// Each slot is bordered on every side and pulled back a pixel, rather than sharing one neighbour's
|
|
357
|
+
// divider, so that a row which wraps is still closed at the end it starts from — see the comment in
|
|
358
|
+
// input-otp.tsx, and the geometry check in scripts/large-text.mjs.
|
|
359
|
+
expect(slot).toHaveClass("border", "[&:not(:first-child)]:-ms-px", "first:rounded-s-md", "last:rounded-e-md");
|
|
360
|
+
expect(slot.className).not.toMatch(/\bborder-[rl]\b|\brounded-[lr]-|\bborder-[ye]\b/);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("aligns <MultiSelect>'s create row and its search icon to the inline start", async () => {
|
|
364
|
+
const user = userEvent.setup();
|
|
365
|
+
render(rtl(<MultiSelect aria-label="Fruit" options={OPTIONS} creatable />));
|
|
366
|
+
await user.click(screen.getByRole("combobox", { name: "Fruit" }));
|
|
367
|
+
const search = await screen.findByPlaceholderText("Search…");
|
|
368
|
+
// CommandInput's magnifier sat at `mr-2`, which under RTL is a gap on the far side of the icon and
|
|
369
|
+
// none between the icon and the field it belongs to.
|
|
370
|
+
const icon = search.closest("[cmdk-input-wrapper]")?.querySelector("svg");
|
|
371
|
+
expect(icon).toHaveClass("me-2");
|
|
372
|
+
expect(icon?.getAttribute("class")).not.toMatch(/\bmr-2\b/);
|
|
373
|
+
|
|
374
|
+
await user.type(search, "Durian");
|
|
375
|
+
const create = await screen.findByRole("button", { name: /Durian/ });
|
|
376
|
+
expect(create).toHaveClass("text-start");
|
|
377
|
+
expect(create.className).not.toMatch(/\btext-left\b/);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("hangs <MultiSelect>'s chip remove control off the inline end", async () => {
|
|
381
|
+
render(rtl(<MultiSelect aria-label="Fruit" options={OPTIONS} defaultValue={["apple"]} />));
|
|
382
|
+
// Tag's "x" is positioned by flex order, but its own margins were physical: a 2px nudge toward the
|
|
383
|
+
// right-hand edge of a chip whose end is on the left.
|
|
384
|
+
const remove = screen.getByRole("button", { name: "Remove Apple" });
|
|
385
|
+
expect(remove).toHaveClass("-me-0.5", "ms-0.5");
|
|
386
|
+
expect(remove.className).not.toMatch(/-mr-0\.5|\bml-0\.5\b/);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("keeps <Select>'s tick gutter and label padding on the inline start", async () => {
|
|
390
|
+
const user = userEvent.setup();
|
|
391
|
+
render(
|
|
392
|
+
rtl(
|
|
393
|
+
<Select>
|
|
394
|
+
<SelectTrigger aria-label="Fruit">
|
|
395
|
+
<SelectValue placeholder="Pick one" />
|
|
396
|
+
</SelectTrigger>
|
|
397
|
+
<SelectContent>
|
|
398
|
+
{OPTIONS.map((o) => (
|
|
399
|
+
<SelectItem key={o.value} value={o.value}>
|
|
400
|
+
{o.label}
|
|
401
|
+
</SelectItem>
|
|
402
|
+
))}
|
|
403
|
+
</SelectContent>
|
|
404
|
+
</Select>,
|
|
405
|
+
),
|
|
406
|
+
);
|
|
407
|
+
await user.tab();
|
|
408
|
+
await user.keyboard("{Enter}");
|
|
409
|
+
const option = await screen.findByRole("option", { name: "Apple" });
|
|
410
|
+
expect(option).toHaveClass("ps-8", "pe-2");
|
|
411
|
+
expect(option.className).not.toMatch(/\bpl-8\b|\bpr-2\b/);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("leaves <Input> and <Textarea> to the browser's own bidi handling", async () => {
|
|
415
|
+
const user = userEvent.setup();
|
|
416
|
+
render(
|
|
417
|
+
rtl(
|
|
418
|
+
<>
|
|
419
|
+
<Input aria-label="عنوان" placeholder="اكتب هنا" />
|
|
420
|
+
<Textarea aria-label="ملاحظات" />
|
|
421
|
+
</>,
|
|
422
|
+
),
|
|
423
|
+
);
|
|
424
|
+
const field = screen.getByLabelText("عنوان") as HTMLInputElement;
|
|
425
|
+
// Both are single symmetric `px-3` boxes with no adornment and no `text-left`, so there is nothing
|
|
426
|
+
// to mirror: direction, caret and placeholder alignment are inherited, which is the correct answer
|
|
427
|
+
// for a field that may hold Arabic, Latin or both. Pinned so an adornment cannot be added later
|
|
428
|
+
// without a direction decision.
|
|
429
|
+
for (const el of [field, screen.getByLabelText("ملاحظات")]) {
|
|
430
|
+
expect(el.className).not.toMatch(/\bp[lr]-\d|\bm[lr]-\d|\btext-(?:left|right)\b|\bborder-[lr]\b/);
|
|
431
|
+
}
|
|
432
|
+
await user.type(field, "مرحبا abc");
|
|
433
|
+
expect(field.value).toBe("مرحبا abc");
|
|
434
|
+
});
|
|
435
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { Banner } from "./components/banner";
|
|
6
|
+
import { Inform } from "./components/inform";
|
|
7
|
+
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbSeparator } from "./components/breadcrumb";
|
|
8
|
+
import { NavigationBar } from "./components/navigation-bar";
|
|
9
|
+
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "./components/pagination";
|
|
10
|
+
import { TreeItem, TreeView } from "./components/tree-view";
|
|
11
|
+
import { JsonViewer } from "./components/json-viewer";
|
|
12
|
+
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./components/accordion";
|
|
13
|
+
import { IconsDismissIconExample, IconsIconOnlyButtonExample } from "./usage/icons";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* components-icons.test.tsx — the icon contract (/docs/icons, docs/audits/ICON-ARCHITECTURE-AUDIT.md).
|
|
17
|
+
*
|
|
18
|
+
* Component logic in jsdom: which icon renders, whether a consumer's icon replaces it, what assistive
|
|
19
|
+
* technology is told, and which class a directional glyph carries. jsdom does no layout, so the classes
|
|
20
|
+
* prove intent only — which way a chevron actually points in a right-to-left page is measured on pixels
|
|
21
|
+
* by `pnpm check:icon-direction`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** A consumer's own icon: not lucide, not KinetixUI — any SVG a product team ships. */
|
|
25
|
+
const CompanyClose = () => (
|
|
26
|
+
<svg data-testid="company-close" viewBox="0 0 24 24">
|
|
27
|
+
<path d="M5 5l14 14M19 5L5 19" stroke="currentColor" />
|
|
28
|
+
</svg>
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
describe.each([
|
|
32
|
+
["Banner", Banner],
|
|
33
|
+
["Inform", Inform],
|
|
34
|
+
] as const)("%s dismiss icon", (_name, Component) => {
|
|
35
|
+
it("renders lucide's X by default, hidden from assistive technology, inside a named button", () => {
|
|
36
|
+
render(<Component onDismiss={() => {}}>Saved</Component>);
|
|
37
|
+
const button = screen.getByRole("button", { name: "Dismiss" });
|
|
38
|
+
const svg = button.querySelector("svg")!;
|
|
39
|
+
expect(svg.classList.contains("lucide-x")).toBe(true);
|
|
40
|
+
expect(svg.getAttribute("aria-hidden")).toBe("true");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("replaces the default with the consumer's icon, keeping the name, the action and the size contract", async () => {
|
|
44
|
+
const onDismiss = vi.fn();
|
|
45
|
+
render(
|
|
46
|
+
<Component onDismiss={onDismiss} dismissIcon={<CompanyClose />}>
|
|
47
|
+
Saved
|
|
48
|
+
</Component>,
|
|
49
|
+
);
|
|
50
|
+
const button = screen.getByRole("button", { name: "Dismiss" });
|
|
51
|
+
expect(button.querySelector(".lucide-x")).toBeNull();
|
|
52
|
+
expect(button.querySelector('[data-testid="company-close"]')).not.toBeNull();
|
|
53
|
+
// The button sizes any SVG inside it, so the consumer's icon needs no size of its own.
|
|
54
|
+
expect(button.className).toContain("[&_svg]:size-3.5");
|
|
55
|
+
await userEvent.click(button);
|
|
56
|
+
expect(onDismiss).toHaveBeenCalledTimes(1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("names the button from its label, not from the icon's own title", () => {
|
|
60
|
+
render(
|
|
61
|
+
<Component
|
|
62
|
+
onDismiss={() => {}}
|
|
63
|
+
dismissIcon={
|
|
64
|
+
<svg role="img" aria-label="close-x-24">
|
|
65
|
+
<title>close-x-24</title>
|
|
66
|
+
</svg>
|
|
67
|
+
}
|
|
68
|
+
>
|
|
69
|
+
Saved
|
|
70
|
+
</Component>,
|
|
71
|
+
);
|
|
72
|
+
expect(screen.getByRole("button", { name: "Dismiss" })).toBeTruthy();
|
|
73
|
+
expect(screen.queryByRole("button", { name: /close-x-24/ })).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("renders no dismiss control, and so no icon, without onDismiss", () => {
|
|
77
|
+
render(<Component dismissIcon={<CompanyClose />}>Saved</Component>);
|
|
78
|
+
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
|
79
|
+
expect(screen.queryByTestId("company-close")).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("directional icons", () => {
|
|
84
|
+
const MIRROR = "rtl:-scale-x-100";
|
|
85
|
+
|
|
86
|
+
it("mirrors Previous / Next, Back and the breadcrumb separator with the reading direction", () => {
|
|
87
|
+
const { container } = render(
|
|
88
|
+
<div dir="rtl">
|
|
89
|
+
<NavigationBar title="Settings" onBack={() => {}} />
|
|
90
|
+
<Breadcrumb>
|
|
91
|
+
<BreadcrumbList>
|
|
92
|
+
<BreadcrumbItem>Home</BreadcrumbItem>
|
|
93
|
+
<BreadcrumbSeparator />
|
|
94
|
+
<BreadcrumbItem>Docs</BreadcrumbItem>
|
|
95
|
+
</BreadcrumbList>
|
|
96
|
+
</Breadcrumb>
|
|
97
|
+
<Pagination>
|
|
98
|
+
<PaginationContent>
|
|
99
|
+
<PaginationItem>
|
|
100
|
+
<PaginationPrevious href="#" />
|
|
101
|
+
</PaginationItem>
|
|
102
|
+
<PaginationItem>
|
|
103
|
+
<PaginationNext href="#" />
|
|
104
|
+
</PaginationItem>
|
|
105
|
+
</PaginationContent>
|
|
106
|
+
</Pagination>
|
|
107
|
+
</div>,
|
|
108
|
+
);
|
|
109
|
+
const back = screen.getByRole("button", { name: "Back" }).querySelector("svg")!;
|
|
110
|
+
const previous = screen.getByRole("link", { name: "Go to previous page" }).querySelector("svg")!;
|
|
111
|
+
const next = screen.getByRole("link", { name: "Go to next page" }).querySelector("svg")!;
|
|
112
|
+
const separator = container.querySelector('li[role="presentation"] svg')!;
|
|
113
|
+
for (const svg of [back, previous, next, separator]) {
|
|
114
|
+
expect(svg.getAttribute("class")).toContain(MIRROR);
|
|
115
|
+
expect(svg.getAttribute("aria-hidden")).toBe("true");
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("lets a breadcrumb separator be replaced, and leaves the replacement alone", () => {
|
|
120
|
+
const { container } = render(
|
|
121
|
+
<Breadcrumb>
|
|
122
|
+
<BreadcrumbList>
|
|
123
|
+
<BreadcrumbItem>Home</BreadcrumbItem>
|
|
124
|
+
<BreadcrumbSeparator>/</BreadcrumbSeparator>
|
|
125
|
+
<BreadcrumbItem>Docs</BreadcrumbItem>
|
|
126
|
+
</BreadcrumbList>
|
|
127
|
+
</Breadcrumb>,
|
|
128
|
+
);
|
|
129
|
+
const separator = container.querySelector('li[role="presentation"]')!;
|
|
130
|
+
expect(separator.querySelector("svg")).toBeNull();
|
|
131
|
+
expect(separator.textContent).toBe("/");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("mirrors a collapsed disclosure chevron and only rotates an expanded one", () => {
|
|
135
|
+
render(
|
|
136
|
+
<TreeView defaultExpanded={["open"]}>
|
|
137
|
+
<TreeItem value="open" label="Open">
|
|
138
|
+
<TreeItem value="leaf" label="Leaf" />
|
|
139
|
+
</TreeItem>
|
|
140
|
+
<TreeItem value="shut" label="Shut">
|
|
141
|
+
<TreeItem value="leaf2" label="Leaf 2" />
|
|
142
|
+
</TreeItem>
|
|
143
|
+
</TreeView>,
|
|
144
|
+
);
|
|
145
|
+
const chevron = (label: string) =>
|
|
146
|
+
screen.getByRole("treeitem", { name: label }).querySelector("svg.lucide-chevron-right")!.getAttribute("class")!;
|
|
147
|
+
// Mirroring then rotating a right-pointing chevron would point it up, so the expanded one only rotates.
|
|
148
|
+
expect(chevron("Open")).toContain("rotate-90");
|
|
149
|
+
expect(chevron("Open")).not.toContain(MIRROR);
|
|
150
|
+
expect(chevron("Shut")).toContain(MIRROR);
|
|
151
|
+
expect(chevron("Shut")).not.toContain("rotate-90");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("applies the same rule to the JSON viewer's disclosure", () => {
|
|
155
|
+
const { container } = render(<JsonViewer data={{ a: { b: 1 }, c: { d: 2 } }} expandDepth={0} />);
|
|
156
|
+
const chevrons = [...container.querySelectorAll("svg.lucide-chevron-right")];
|
|
157
|
+
expect(chevrons.length).toBeGreaterThan(0);
|
|
158
|
+
for (const svg of chevrons) expect(svg.getAttribute("class")).toContain(MIRROR);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("does not mirror an icon whose meaning is independent of direction", () => {
|
|
162
|
+
render(
|
|
163
|
+
<div dir="rtl">
|
|
164
|
+
<Accordion type="single" collapsible>
|
|
165
|
+
<AccordionItem value="a">
|
|
166
|
+
<AccordionTrigger>Details</AccordionTrigger>
|
|
167
|
+
<AccordionContent>Body</AccordionContent>
|
|
168
|
+
</AccordionItem>
|
|
169
|
+
</Accordion>
|
|
170
|
+
<Banner onDismiss={() => {}}>Saved</Banner>
|
|
171
|
+
</div>,
|
|
172
|
+
);
|
|
173
|
+
const down = screen.getByRole("button", { name: "Details" }).querySelector("svg")!;
|
|
174
|
+
const close = screen.getByRole("button", { name: "Dismiss" }).querySelector("svg")!;
|
|
175
|
+
expect(down.getAttribute("class")).not.toContain(MIRROR);
|
|
176
|
+
expect(close.getAttribute("class")).not.toContain(MIRROR);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("the /docs/icons example", () => {
|
|
181
|
+
it("renders the snippet the docs page shows: default, another lucide icon, and an icon of the app's own", () => {
|
|
182
|
+
render(<IconsDismissIconExample />);
|
|
183
|
+
const buttons = screen.getAllByRole("button", { name: "Dismiss" });
|
|
184
|
+
expect(buttons).toHaveLength(3);
|
|
185
|
+
expect(buttons[0].querySelector(".lucide-x")).not.toBeNull();
|
|
186
|
+
expect(buttons[1].querySelector(".lucide-circle-x")).not.toBeNull();
|
|
187
|
+
expect(buttons[2].querySelector("svg[class*=lucide]")).toBeNull();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("renders the icon-only button the docs page shows: named by its text, icon hidden, text visually hidden", () => {
|
|
191
|
+
render(<IconsIconOnlyButtonExample />);
|
|
192
|
+
const button = screen.getByRole("button", { name: "Delete draft" });
|
|
193
|
+
expect(button.querySelector("svg")!.getAttribute("aria-hidden")).toBe("true");
|
|
194
|
+
// The label is in the DOM for assistive technology; size="icon" hides it visually.
|
|
195
|
+
expect(button.className).toContain("[&>*:not(svg)]:sr-only");
|
|
196
|
+
});
|
|
197
|
+
});
|