@primitiv-ui/react 0.1.21 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/src/ContextMenu/README.md +1 -1
- package/src/ContextMenu/__tests__/ContextMenu.checkbox-item.test.tsx +23 -0
- package/src/ContextMenu/__tests__/ContextMenu.keyboard-interaction.test.tsx +56 -1
- package/src/ContextMenu/__tests__/ContextMenu.mouse-interaction.test.tsx +41 -0
- package/src/ContextMenu/__tests__/ContextMenu.radio-item.test.tsx +25 -0
- package/src/ContextMenu/__tests__/ContextMenu.sub.test.tsx +145 -0
- package/src/ContextMenu/__tests__/ContextMenu.typeahead.test.tsx +46 -1
- package/src/Dropdown/README.md +1 -1
- package/src/Field/__tests__/Field.contract.test.tsx +5 -1
- package/src/Field/hooks/__tests__/useFieldProps.test.tsx +47 -0
- package/src/InputGroup/__tests__/InputGroup.asChild.test.tsx +3 -2
- package/src/Modal/README.md +2 -2
- package/src/RadioGroup/README.md +2 -2
- package/src/Select/__tests__/Select.placeholder.test.tsx +18 -0
- package/src/Switch/README.md +1 -1
- package/src/Tooltip/README.md +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@primitiv-ui/react",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"description": "Headless, accessible React components built on the WAI-ARIA authoring patterns. Zero styles ship with this package — bring your own (CSS, Tailwind, CSS-in-JS, design tokens).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"scripts": {
|
|
57
57
|
"qa": "pnpm qa:units",
|
|
58
58
|
"qa:units": "vitest run --coverage",
|
|
59
|
-
"qa:units:watch": "vitest --coverage"
|
|
59
|
+
"qa:units:watch": "vitest --coverage",
|
|
60
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
60
61
|
}
|
|
61
62
|
}
|
|
@@ -252,7 +252,7 @@ ARIA attributes, event handlers, and ref onto a caller-supplied child:
|
|
|
252
252
|
```css
|
|
253
253
|
/* Open state on the menu panel */
|
|
254
254
|
[role="menu"][data-state="open"] {
|
|
255
|
-
animation: fade-in
|
|
255
|
+
animation: fade-in var(--primitiv-motion-duration-control) var(--primitiv-motion-easing-enter);
|
|
256
256
|
}
|
|
257
257
|
|
|
258
258
|
/* Highlighted item — pointer focus */
|
|
@@ -142,4 +142,27 @@ describe("ContextMenu.CheckboxItem", () => {
|
|
|
142
142
|
expect(onCheckedChange).not.toHaveBeenCalled();
|
|
143
143
|
expect(item).toHaveAttribute("aria-disabled", "true");
|
|
144
144
|
});
|
|
145
|
+
|
|
146
|
+
it("highlights on pointer enter and clears the highlight on pointer leave", () => {
|
|
147
|
+
// Arrange
|
|
148
|
+
render(
|
|
149
|
+
<ContextMenu.Root defaultOpen>
|
|
150
|
+
<ContextMenu.Trigger>Area</ContextMenu.Trigger>
|
|
151
|
+
<ContextMenu.Content>
|
|
152
|
+
<ContextMenu.CheckboxItem>Show grid</ContextMenu.CheckboxItem>
|
|
153
|
+
</ContextMenu.Content>
|
|
154
|
+
</ContextMenu.Root>,
|
|
155
|
+
);
|
|
156
|
+
const item = screen.getByRole("menuitemcheckbox", {
|
|
157
|
+
name: "Show grid",
|
|
158
|
+
hidden: true,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Act & Assert — enter highlights, leave clears it.
|
|
162
|
+
fireEvent.mouseEnter(item);
|
|
163
|
+
expect(item).toHaveAttribute("data-highlighted");
|
|
164
|
+
|
|
165
|
+
fireEvent.mouseLeave(item);
|
|
166
|
+
expect(item).not.toHaveAttribute("data-highlighted");
|
|
167
|
+
});
|
|
145
168
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, screen } from "@testing-library/react";
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
2
|
import userEvent from "@testing-library/user-event";
|
|
3
3
|
|
|
4
4
|
import { ContextMenu } from "../ContextMenu";
|
|
@@ -169,4 +169,59 @@ describe("ContextMenu keyboard interaction", () => {
|
|
|
169
169
|
screen.getByRole("menuitem", { name: "Delete", hidden: true }),
|
|
170
170
|
).toHaveFocus();
|
|
171
171
|
});
|
|
172
|
+
|
|
173
|
+
it("ArrowUp from a middle item moves to the previous item", async () => {
|
|
174
|
+
// Arrange — drives the non-wrapping branch (currentIndex - 1).
|
|
175
|
+
const user = userEvent.setup();
|
|
176
|
+
renderMenu();
|
|
177
|
+
await user.keyboard("{ArrowDown}");
|
|
178
|
+
expect(
|
|
179
|
+
screen.getByRole("menuitem", { name: "Duplicate", hidden: true }),
|
|
180
|
+
).toHaveFocus();
|
|
181
|
+
|
|
182
|
+
// Act
|
|
183
|
+
await user.keyboard("{ArrowUp}");
|
|
184
|
+
|
|
185
|
+
// Assert
|
|
186
|
+
expect(
|
|
187
|
+
screen.getByRole("menuitem", { name: "Rename", hidden: true }),
|
|
188
|
+
).toHaveFocus();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("ignores arrow keys when the menu has no items", () => {
|
|
192
|
+
// Arrange — an empty Content has nothing to focus, so activeElement is the
|
|
193
|
+
// body, exercising the no-popover-scope fallback and the empty-items guard.
|
|
194
|
+
render(
|
|
195
|
+
<ContextMenu.Root defaultOpen>
|
|
196
|
+
<ContextMenu.Trigger>Area</ContextMenu.Trigger>
|
|
197
|
+
<ContextMenu.Content />
|
|
198
|
+
</ContextMenu.Root>,
|
|
199
|
+
);
|
|
200
|
+
const menu = screen.getByRole("menu", { hidden: true });
|
|
201
|
+
|
|
202
|
+
// Act + Assert — no throw, menu stays open.
|
|
203
|
+
expect(() => fireEvent.keyDown(menu, { key: "ArrowDown" })).not.toThrow();
|
|
204
|
+
expect(menu).toBeInTheDocument();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("Enter does nothing when no item is focused", () => {
|
|
208
|
+
// Arrange — blur the auto-focused first item so currentIndex resolves to -1.
|
|
209
|
+
const onSelect = vi.fn();
|
|
210
|
+
render(
|
|
211
|
+
<ContextMenu.Root defaultOpen>
|
|
212
|
+
<ContextMenu.Trigger>Area</ContextMenu.Trigger>
|
|
213
|
+
<ContextMenu.Content>
|
|
214
|
+
<ContextMenu.Item onSelect={onSelect}>Rename</ContextMenu.Item>
|
|
215
|
+
</ContextMenu.Content>
|
|
216
|
+
</ContextMenu.Root>,
|
|
217
|
+
);
|
|
218
|
+
const menu = screen.getByRole("menu", { hidden: true });
|
|
219
|
+
(document.activeElement as HTMLElement | null)?.blur();
|
|
220
|
+
|
|
221
|
+
// Act
|
|
222
|
+
fireEvent.keyDown(menu, { key: "Enter" });
|
|
223
|
+
|
|
224
|
+
// Assert — nothing was activated.
|
|
225
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
226
|
+
});
|
|
172
227
|
});
|
|
@@ -128,6 +128,47 @@ describe("ContextMenu mouse interaction", () => {
|
|
|
128
128
|
expect(item).not.toHaveAttribute("data-highlighted");
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
+
it("keeps the menu open when the click lands on the trigger itself", async () => {
|
|
132
|
+
// The outside-click dismissal must exempt the trigger so a click within it
|
|
133
|
+
// does not immediately re-close a just-opened menu.
|
|
134
|
+
const user = userEvent.setup();
|
|
135
|
+
const onOpenChange = vi.fn();
|
|
136
|
+
render(
|
|
137
|
+
<ContextMenu.Root defaultOpen onOpenChange={onOpenChange}>
|
|
138
|
+
<ContextMenu.Trigger>Area</ContextMenu.Trigger>
|
|
139
|
+
<ContextMenu.Content>
|
|
140
|
+
<ContextMenu.Item>Rename</ContextMenu.Item>
|
|
141
|
+
</ContextMenu.Content>
|
|
142
|
+
</ContextMenu.Root>,
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// Act
|
|
146
|
+
await user.click(screen.getByText("Area"));
|
|
147
|
+
|
|
148
|
+
// Assert — the trigger-contained click did not request a close.
|
|
149
|
+
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("keeps the menu open when the click lands inside the content popover", async () => {
|
|
153
|
+
// A click on the menu surface (but not an item) must not dismiss it.
|
|
154
|
+
const user = userEvent.setup();
|
|
155
|
+
const onOpenChange = vi.fn();
|
|
156
|
+
render(
|
|
157
|
+
<ContextMenu.Root defaultOpen onOpenChange={onOpenChange}>
|
|
158
|
+
<ContextMenu.Trigger>Area</ContextMenu.Trigger>
|
|
159
|
+
<ContextMenu.Content>
|
|
160
|
+
<ContextMenu.Item>Rename</ContextMenu.Item>
|
|
161
|
+
</ContextMenu.Content>
|
|
162
|
+
</ContextMenu.Root>,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// Act
|
|
166
|
+
await user.click(screen.getByRole("menu", { hidden: true }));
|
|
167
|
+
|
|
168
|
+
// Assert
|
|
169
|
+
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
131
172
|
it("closes the menu when the user clicks outside both the trigger and the content", async () => {
|
|
132
173
|
// Arrange
|
|
133
174
|
const user = userEvent.setup();
|
|
@@ -124,4 +124,29 @@ describe("ContextMenu.RadioGroup + ContextMenu.RadioItem", () => {
|
|
|
124
124
|
// Assert
|
|
125
125
|
expect(onValueChange).not.toHaveBeenCalled();
|
|
126
126
|
});
|
|
127
|
+
|
|
128
|
+
it("highlights a RadioItem on pointer enter and clears it on pointer leave", () => {
|
|
129
|
+
// Arrange
|
|
130
|
+
render(
|
|
131
|
+
<ContextMenu.Root defaultOpen>
|
|
132
|
+
<ContextMenu.Trigger>Area</ContextMenu.Trigger>
|
|
133
|
+
<ContextMenu.Content>
|
|
134
|
+
<ContextMenu.RadioGroup defaultValue="light">
|
|
135
|
+
<ContextMenu.RadioItem value="light">Light</ContextMenu.RadioItem>
|
|
136
|
+
</ContextMenu.RadioGroup>
|
|
137
|
+
</ContextMenu.Content>
|
|
138
|
+
</ContextMenu.Root>,
|
|
139
|
+
);
|
|
140
|
+
const item = screen.getByRole("menuitemradio", {
|
|
141
|
+
name: "Light",
|
|
142
|
+
hidden: true,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Act & Assert — enter highlights, leave clears it.
|
|
146
|
+
fireEvent.mouseEnter(item);
|
|
147
|
+
expect(item).toHaveAttribute("data-highlighted");
|
|
148
|
+
|
|
149
|
+
fireEvent.mouseLeave(item);
|
|
150
|
+
expect(item).not.toHaveAttribute("data-highlighted");
|
|
151
|
+
});
|
|
127
152
|
});
|
|
@@ -259,4 +259,149 @@ describe("ContextMenu.Sub", () => {
|
|
|
259
259
|
// Assert
|
|
260
260
|
expect(subTrigger).toHaveAttribute("aria-expanded", "false");
|
|
261
261
|
});
|
|
262
|
+
|
|
263
|
+
it("opens the sub-menu when the SubTrigger is clicked", () => {
|
|
264
|
+
// Arrange
|
|
265
|
+
render(
|
|
266
|
+
<ContextMenu.Root defaultOpen>
|
|
267
|
+
<ContextMenu.Trigger>Area</ContextMenu.Trigger>
|
|
268
|
+
<ContextMenu.Content>
|
|
269
|
+
<ContextMenu.Sub>
|
|
270
|
+
<ContextMenu.SubTrigger>More</ContextMenu.SubTrigger>
|
|
271
|
+
<ContextMenu.SubContent>
|
|
272
|
+
<ContextMenu.Item>Nested</ContextMenu.Item>
|
|
273
|
+
</ContextMenu.SubContent>
|
|
274
|
+
</ContextMenu.Sub>
|
|
275
|
+
</ContextMenu.Content>
|
|
276
|
+
</ContextMenu.Root>,
|
|
277
|
+
);
|
|
278
|
+
const subTrigger = screen.getByRole("menuitem", {
|
|
279
|
+
name: "More",
|
|
280
|
+
hidden: true,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Act
|
|
284
|
+
fireEvent.click(subTrigger);
|
|
285
|
+
|
|
286
|
+
// Assert
|
|
287
|
+
expect(subTrigger).toHaveAttribute("aria-expanded", "true");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("does not open on click when the SubTrigger is disabled", () => {
|
|
291
|
+
// Arrange
|
|
292
|
+
render(
|
|
293
|
+
<ContextMenu.Root defaultOpen>
|
|
294
|
+
<ContextMenu.Trigger>Area</ContextMenu.Trigger>
|
|
295
|
+
<ContextMenu.Content>
|
|
296
|
+
<ContextMenu.Sub>
|
|
297
|
+
<ContextMenu.SubTrigger disabled>More</ContextMenu.SubTrigger>
|
|
298
|
+
<ContextMenu.SubContent>
|
|
299
|
+
<ContextMenu.Item>Nested</ContextMenu.Item>
|
|
300
|
+
</ContextMenu.SubContent>
|
|
301
|
+
</ContextMenu.Sub>
|
|
302
|
+
</ContextMenu.Content>
|
|
303
|
+
</ContextMenu.Root>,
|
|
304
|
+
);
|
|
305
|
+
const subTrigger = screen.getByRole("menuitem", {
|
|
306
|
+
name: "More",
|
|
307
|
+
hidden: true,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Act
|
|
311
|
+
fireEvent.click(subTrigger);
|
|
312
|
+
|
|
313
|
+
// Assert
|
|
314
|
+
expect(subTrigger).toHaveAttribute("aria-expanded", "false");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("ignores the open arrow key when the SubTrigger is disabled", () => {
|
|
318
|
+
// Arrange
|
|
319
|
+
render(
|
|
320
|
+
<ContextMenu.Root defaultOpen>
|
|
321
|
+
<ContextMenu.Trigger>Area</ContextMenu.Trigger>
|
|
322
|
+
<ContextMenu.Content>
|
|
323
|
+
<ContextMenu.Sub>
|
|
324
|
+
<ContextMenu.SubTrigger disabled>More</ContextMenu.SubTrigger>
|
|
325
|
+
<ContextMenu.SubContent>
|
|
326
|
+
<ContextMenu.Item>Nested</ContextMenu.Item>
|
|
327
|
+
</ContextMenu.SubContent>
|
|
328
|
+
</ContextMenu.Sub>
|
|
329
|
+
</ContextMenu.Content>
|
|
330
|
+
</ContextMenu.Root>,
|
|
331
|
+
);
|
|
332
|
+
const subTrigger = screen.getByRole("menuitem", {
|
|
333
|
+
name: "More",
|
|
334
|
+
hidden: true,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// Act
|
|
338
|
+
fireEvent.keyDown(subTrigger, { key: "ArrowRight" });
|
|
339
|
+
|
|
340
|
+
// Assert
|
|
341
|
+
expect(subTrigger).toHaveAttribute("aria-expanded", "false");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("closes an already-open sibling sub when a second sub opens via click", () => {
|
|
345
|
+
// The hover path closes the prior sub up front via closeSiblingSub; opening
|
|
346
|
+
// by click skips that, so the sub's own registration effect must supplant
|
|
347
|
+
// the previously registered sibling instead.
|
|
348
|
+
render(
|
|
349
|
+
<ContextMenu.Root defaultOpen>
|
|
350
|
+
<ContextMenu.Trigger>Area</ContextMenu.Trigger>
|
|
351
|
+
<ContextMenu.Content>
|
|
352
|
+
<ContextMenu.Sub>
|
|
353
|
+
<ContextMenu.SubTrigger>Share</ContextMenu.SubTrigger>
|
|
354
|
+
<ContextMenu.SubContent>
|
|
355
|
+
<ContextMenu.Item>Email</ContextMenu.Item>
|
|
356
|
+
</ContextMenu.SubContent>
|
|
357
|
+
</ContextMenu.Sub>
|
|
358
|
+
<ContextMenu.Sub>
|
|
359
|
+
<ContextMenu.SubTrigger>Move to</ContextMenu.SubTrigger>
|
|
360
|
+
<ContextMenu.SubContent>
|
|
361
|
+
<ContextMenu.Item>Trash</ContextMenu.Item>
|
|
362
|
+
</ContextMenu.SubContent>
|
|
363
|
+
</ContextMenu.Sub>
|
|
364
|
+
</ContextMenu.Content>
|
|
365
|
+
</ContextMenu.Root>,
|
|
366
|
+
);
|
|
367
|
+
const a = screen.getByRole("menuitem", { name: "Share", hidden: true });
|
|
368
|
+
const b = screen.getByRole("menuitem", { name: "Move to", hidden: true });
|
|
369
|
+
|
|
370
|
+
// Act — click opens A, then clicking B must supplant A.
|
|
371
|
+
fireEvent.click(a);
|
|
372
|
+
expect(a).toHaveAttribute("aria-expanded", "true");
|
|
373
|
+
fireEvent.click(b);
|
|
374
|
+
|
|
375
|
+
// Assert
|
|
376
|
+
expect(a).toHaveAttribute("aria-expanded", "false");
|
|
377
|
+
expect(b).toHaveAttribute("aria-expanded", "true");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("leaves the sub-menu open when a non-close key is pressed inside the SubContent", () => {
|
|
381
|
+
// Arrange
|
|
382
|
+
render(
|
|
383
|
+
<ContextMenu.Root defaultOpen>
|
|
384
|
+
<ContextMenu.Trigger>Area</ContextMenu.Trigger>
|
|
385
|
+
<ContextMenu.Content>
|
|
386
|
+
<ContextMenu.Sub defaultOpen>
|
|
387
|
+
<ContextMenu.SubTrigger>More</ContextMenu.SubTrigger>
|
|
388
|
+
<ContextMenu.SubContent>
|
|
389
|
+
<ContextMenu.Item>Nested</ContextMenu.Item>
|
|
390
|
+
</ContextMenu.SubContent>
|
|
391
|
+
</ContextMenu.Sub>
|
|
392
|
+
</ContextMenu.Content>
|
|
393
|
+
</ContextMenu.Root>,
|
|
394
|
+
);
|
|
395
|
+
const subTrigger = screen.getByRole("menuitem", {
|
|
396
|
+
name: "More",
|
|
397
|
+
hidden: true,
|
|
398
|
+
});
|
|
399
|
+
const [, subMenu] = screen.getAllByRole("menu", { hidden: true });
|
|
400
|
+
|
|
401
|
+
// Act — a key other than the close (inline-backward) key must be ignored.
|
|
402
|
+
fireEvent.keyDown(subMenu, { key: "ArrowDown" });
|
|
403
|
+
|
|
404
|
+
// Assert
|
|
405
|
+
expect(subTrigger).toHaveAttribute("aria-expanded", "true");
|
|
406
|
+
});
|
|
262
407
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { render, screen } from "@testing-library/react";
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
2
|
import userEvent from "@testing-library/user-event";
|
|
3
3
|
|
|
4
4
|
import { ContextMenu } from "../ContextMenu";
|
|
@@ -86,4 +86,49 @@ describe("ContextMenu typeahead", () => {
|
|
|
86
86
|
screen.getByRole("menuitem", { name: "Blueberry", hidden: true }),
|
|
87
87
|
).toHaveFocus();
|
|
88
88
|
});
|
|
89
|
+
|
|
90
|
+
it("resets the typed query after the typeahead window elapses", () => {
|
|
91
|
+
// Arrange — fake timers so the reset timeout can be flushed deterministically.
|
|
92
|
+
vi.useFakeTimers();
|
|
93
|
+
try {
|
|
94
|
+
renderMenu();
|
|
95
|
+
const menu = screen.getByRole("menu", { hidden: true });
|
|
96
|
+
|
|
97
|
+
// First keystroke matches Banana and arms the reset timer.
|
|
98
|
+
fireEvent.keyDown(menu, { key: "b" });
|
|
99
|
+
expect(
|
|
100
|
+
screen.getByRole("menuitem", { name: "Banana", hidden: true }),
|
|
101
|
+
).toHaveFocus();
|
|
102
|
+
|
|
103
|
+
// Let the window lapse so the query clears.
|
|
104
|
+
vi.advanceTimersByTime(5000);
|
|
105
|
+
|
|
106
|
+
// Act — a fresh "a" must be treated as a new search, not appended to "b"
|
|
107
|
+
// (which would match nothing and leave focus on Banana).
|
|
108
|
+
fireEvent.keyDown(menu, { key: "a" });
|
|
109
|
+
|
|
110
|
+
// Assert
|
|
111
|
+
expect(
|
|
112
|
+
screen.getByRole("menuitem", { name: "Apple", hidden: true }),
|
|
113
|
+
).toHaveFocus();
|
|
114
|
+
} finally {
|
|
115
|
+
vi.useRealTimers();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("starts the search from the top when no item is focused", () => {
|
|
120
|
+
// Arrange — blur the auto-focused item so currentIndex resolves to -1 and
|
|
121
|
+
// the search starts at index 0.
|
|
122
|
+
renderMenu();
|
|
123
|
+
const menu = screen.getByRole("menu", { hidden: true });
|
|
124
|
+
(document.activeElement as HTMLElement | null)?.blur();
|
|
125
|
+
|
|
126
|
+
// Act
|
|
127
|
+
fireEvent.keyDown(menu, { key: "a" });
|
|
128
|
+
|
|
129
|
+
// Assert
|
|
130
|
+
expect(
|
|
131
|
+
screen.getByRole("menuitem", { name: "Apple", hidden: true }),
|
|
132
|
+
).toHaveFocus();
|
|
133
|
+
});
|
|
89
134
|
});
|
package/src/Dropdown/README.md
CHANGED
|
@@ -250,7 +250,7 @@ the component's):
|
|
|
250
250
|
```css
|
|
251
251
|
/* Open state on the menu panel */
|
|
252
252
|
[role="menu"][data-popover-open] {
|
|
253
|
-
animation: fade-in
|
|
253
|
+
animation: fade-in var(--primitiv-motion-duration-control) var(--primitiv-motion-easing-enter);
|
|
254
254
|
}
|
|
255
255
|
|
|
256
256
|
/* Highlighted item — pointer focus */
|
|
@@ -49,7 +49,11 @@ describe("Field styling contract", () => {
|
|
|
49
49
|
|
|
50
50
|
it("emits exactly the contracted data-* attribute names across its states", () => {
|
|
51
51
|
const emitted = new Set<string>();
|
|
52
|
-
|
|
52
|
+
const states: Record<string, boolean>[] = [
|
|
53
|
+
{},
|
|
54
|
+
{ invalid: true, disabled: true, required: true },
|
|
55
|
+
];
|
|
56
|
+
for (const props of states) {
|
|
53
57
|
for (const name of dataAttributeNames(renderRoot(props))) {
|
|
54
58
|
emitted.add(name);
|
|
55
59
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
|
|
3
|
+
import { FieldContext, type FieldContextValue } from "../../FieldContext";
|
|
4
|
+
import { useFieldProps } from "../useFieldProps";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `useFieldProps` is a reusable hook that reads {@link FieldContext} directly,
|
|
8
|
+
* so it is exercised here against a hand-built provider value rather than the
|
|
9
|
+
* full `Field.Root`. `Field.Root` always supplies a truthy `descriptionId`,
|
|
10
|
+
* which means its defensive "nothing to describe" fallback is only reachable
|
|
11
|
+
* through a custom provider like the one below.
|
|
12
|
+
*/
|
|
13
|
+
function Probe(props: { "aria-describedby"?: string }) {
|
|
14
|
+
const merged = useFieldProps(props);
|
|
15
|
+
return <input data-testid="probe" {...merged} />;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function renderInField(
|
|
19
|
+
field: Partial<FieldContextValue>,
|
|
20
|
+
props: { "aria-describedby"?: string } = {},
|
|
21
|
+
) {
|
|
22
|
+
const value: FieldContextValue = {
|
|
23
|
+
id: "f",
|
|
24
|
+
descriptionId: "",
|
|
25
|
+
errorId: "",
|
|
26
|
+
invalid: false,
|
|
27
|
+
disabled: false,
|
|
28
|
+
required: false,
|
|
29
|
+
...field,
|
|
30
|
+
};
|
|
31
|
+
return render(
|
|
32
|
+
<FieldContext.Provider value={value}>
|
|
33
|
+
<Probe {...props} />
|
|
34
|
+
</FieldContext.Provider>,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("useFieldProps aria-describedby composition", () => {
|
|
39
|
+
it("omits aria-describedby entirely when nothing contributes an id", () => {
|
|
40
|
+
// Arrange & Act — empty description/error ids, not invalid, no consumer id.
|
|
41
|
+
renderInField({});
|
|
42
|
+
|
|
43
|
+
// Assert — the empty join collapses to undefined, so the attribute is absent
|
|
44
|
+
// rather than rendered as aria-describedby="".
|
|
45
|
+
expect(screen.getByTestId("probe")).not.toHaveAttribute("aria-describedby");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -76,8 +76,9 @@ describe("InputGroup asChild composition", () => {
|
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
it("forwards a ref through Slot on the Root", () => {
|
|
79
|
-
// Arrange
|
|
80
|
-
|
|
79
|
+
// Arrange: Root types its ref as HTMLDivElement (its default element); the
|
|
80
|
+
// asChild <label> still receives it, which the identity assertion confirms.
|
|
81
|
+
const ref = createRef<HTMLDivElement>();
|
|
81
82
|
|
|
82
83
|
// Act
|
|
83
84
|
render(
|
package/src/Modal/README.md
CHANGED
|
@@ -168,10 +168,10 @@ animation can play against the `data-state="closed"` attribute:
|
|
|
168
168
|
|
|
169
169
|
```css
|
|
170
170
|
[data-state="open"] {
|
|
171
|
-
animation: fade-in
|
|
171
|
+
animation: fade-in var(--primitiv-motion-duration-control) var(--primitiv-motion-easing-enter);
|
|
172
172
|
}
|
|
173
173
|
[data-state="closed"] {
|
|
174
|
-
animation: fade-out
|
|
174
|
+
animation: fade-out var(--primitiv-motion-duration-feedback) var(--primitiv-motion-easing-exit) forwards;
|
|
175
175
|
}
|
|
176
176
|
```
|
|
177
177
|
|
package/src/RadioGroup/README.md
CHANGED
|
@@ -159,10 +159,10 @@ animation can play against `data-state="unchecked"`:
|
|
|
159
159
|
|
|
160
160
|
```css
|
|
161
161
|
[data-state="checked"] {
|
|
162
|
-
animation: dot-in
|
|
162
|
+
animation: dot-in var(--primitiv-motion-duration-control) var(--primitiv-motion-easing-enter);
|
|
163
163
|
}
|
|
164
164
|
[data-state="unchecked"] {
|
|
165
|
-
animation: dot-out
|
|
165
|
+
animation: dot-out var(--primitiv-motion-duration-feedback) var(--primitiv-motion-easing-exit) forwards;
|
|
166
166
|
}
|
|
167
167
|
```
|
|
168
168
|
|
|
@@ -29,4 +29,22 @@ describe("Select placeholder", () => {
|
|
|
29
29
|
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
|
30
30
|
expect(select.value).toBe("");
|
|
31
31
|
});
|
|
32
|
+
|
|
33
|
+
it("ignores non-element children when scanning for a placeholder", () => {
|
|
34
|
+
// Arrange & Act — a bare text node sits among the options (e.g. stray
|
|
35
|
+
// whitespace or a conditional that resolved to a string). The placeholder
|
|
36
|
+
// scan must skip it rather than treat it as a candidate.
|
|
37
|
+
const { container } = render(
|
|
38
|
+
<Select.Root>
|
|
39
|
+
{" "}
|
|
40
|
+
<Select.Option value="apple">Apple</Select.Option>
|
|
41
|
+
</Select.Root>,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Assert — with no placeholder present, no empty-value default is inferred,
|
|
45
|
+
// so the browser falls back to the first real option.
|
|
46
|
+
expect(container.querySelector("option[hidden]")).toBeNull();
|
|
47
|
+
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
|
48
|
+
expect(select.value).toBe("apple");
|
|
49
|
+
});
|
|
32
50
|
});
|
package/src/Switch/README.md
CHANGED
|
@@ -99,7 +99,7 @@ animate with `transition` or Web Animations.
|
|
|
99
99
|
background: #6366f1;
|
|
100
100
|
}
|
|
101
101
|
.switch__thumb {
|
|
102
|
-
transition: translate
|
|
102
|
+
transition: translate var(--primitiv-motion-duration-control) var(--primitiv-motion-easing-default);
|
|
103
103
|
}
|
|
104
104
|
.switch > input:checked ~ .switch__thumb {
|
|
105
105
|
translate: 1.25rem 0;
|
package/src/Tooltip/README.md
CHANGED
|
@@ -183,8 +183,8 @@ subtree stays in the DOM while closed so a CSS animation can play on
|
|
|
183
183
|
```
|
|
184
184
|
|
|
185
185
|
```css
|
|
186
|
-
[data-state="open"] { animation: fade-in
|
|
187
|
-
[data-state="closed"] { animation: fade-out
|
|
186
|
+
[data-state="open"] { animation: fade-in var(--primitiv-motion-duration-control) var(--primitiv-motion-easing-enter); }
|
|
187
|
+
[data-state="closed"] { animation: fade-out var(--primitiv-motion-duration-feedback) var(--primitiv-motion-easing-exit) forwards; }
|
|
188
188
|
```
|
|
189
189
|
|
|
190
190
|
## `asChild` composition
|