@primitiv-ui/react 0.1.20 → 0.1.22

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitiv-ui/react",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
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 120ms ease-out;
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
  });
@@ -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 120ms ease-out;
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
- for (const props of [{}, { invalid: true, disabled: true, required: true }]) {
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
- const ref = createRef<HTMLLabelElement>();
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(
@@ -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 150ms ease-out;
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 120ms ease-in forwards;
174
+ animation: fade-out var(--primitiv-motion-duration-feedback) var(--primitiv-motion-easing-exit) forwards;
175
175
  }
176
176
  ```
177
177
 
@@ -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 120ms ease-out;
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 100ms ease-in forwards;
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/Slot/Slot.tsx CHANGED
@@ -20,6 +20,9 @@ import {
20
20
  cloneElement,
21
21
  Children,
22
22
  RefObject,
23
+ type ForwardRefExoticComponent,
24
+ type PropsWithoutRef,
25
+ type RefAttributes,
23
26
  } from "react";
24
27
  import { SlotProps } from "./types";
25
28
  import { AnyProps, PossibleRef } from "../types";
@@ -137,7 +140,9 @@ function mergeProps(slotProps: AnyProps, childProps: AnyProps): AnyProps {
137
140
  * return <button {...buttonProps}>{children}</button>;
138
141
  * ```
139
142
  */
140
- export const Slot = forwardRef<HTMLElement, SlotProps>(
143
+ export const Slot: ForwardRefExoticComponent<
144
+ PropsWithoutRef<SlotProps> & RefAttributes<HTMLElement>
145
+ > = forwardRef<HTMLElement, SlotProps>(
141
146
  ({ children, ...slotProps }, forwardedRef) => {
142
147
  if (Children.count(children) !== 1 || !isValidElement(children)) {
143
148
  throw new Error("Slot requires exactly one React element child.");
@@ -99,7 +99,7 @@ animate with `transition` or Web Animations.
99
99
  background: #6366f1;
100
100
  }
101
101
  .switch__thumb {
102
- transition: translate 120ms ease;
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;
@@ -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 120ms ease-out; }
187
- [data-state="closed"] { animation: fade-out 80ms ease-in forwards; }
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