@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.
Files changed (80) hide show
  1. package/README.md +134 -0
  2. package/dist/index.d.ts +212 -31
  3. package/dist/index.js +1466 -1118
  4. package/dist/kx-src-hash.json +3 -0
  5. package/package.json +7 -7
  6. package/src/components/accordion.tsx +1 -1
  7. package/src/components/alert-dialog.tsx +7 -5
  8. package/src/components/avatar.tsx +4 -1
  9. package/src/components/banner.tsx +9 -3
  10. package/src/components/breadcrumb.tsx +1 -1
  11. package/src/components/button.tsx +1 -1
  12. package/src/components/card.tsx +77 -6
  13. package/src/components/checkbox.tsx +42 -8
  14. package/src/components/collapsible.tsx +41 -1
  15. package/src/components/command.tsx +2 -2
  16. package/src/components/context-menu.tsx +21 -13
  17. package/src/components/dialog.tsx +5 -4
  18. package/src/components/direction-provider.tsx +85 -1
  19. package/src/components/drawer.tsx +62 -18
  20. package/src/components/dropdown-menu.tsx +12 -4
  21. package/src/components/file-upload.tsx +1 -1
  22. package/src/components/hover-card.tsx +1 -1
  23. package/src/components/inform.tsx +9 -3
  24. package/src/components/input-group.tsx +27 -5
  25. package/src/components/input-otp.tsx +48 -5
  26. package/src/components/input.tsx +20 -3
  27. package/src/components/json-viewer.tsx +1 -1
  28. package/src/components/menubar.tsx +21 -15
  29. package/src/components/modal.tsx +3 -2
  30. package/src/components/multi-select.tsx +14 -5
  31. package/src/components/native-select.tsx +10 -2
  32. package/src/components/navigation-bar.tsx +1 -1
  33. package/src/components/navigation-menu.tsx +6 -6
  34. package/src/components/number-input.tsx +28 -10
  35. package/src/components/pagination.tsx +2 -2
  36. package/src/components/popover.tsx +29 -5
  37. package/src/components/radio-group.tsx +23 -8
  38. package/src/components/segmented-control.tsx +20 -4
  39. package/src/components/select.tsx +25 -8
  40. package/src/components/sheet.tsx +4 -3
  41. package/src/components/spinner.tsx +1 -1
  42. package/src/components/switch.tsx +31 -8
  43. package/src/components/table.tsx +121 -8
  44. package/src/components/tabs.tsx +29 -4
  45. package/src/components/tag.tsx +1 -1
  46. package/src/components/textarea.tsx +20 -3
  47. package/src/components/tooltip.tsx +14 -4
  48. package/src/components/tour.tsx +22 -7
  49. package/src/components/tree-view.tsx +1 -1
  50. package/src/components-behavior.test.tsx +74 -0
  51. package/src/components-direction-portal.test.tsx +482 -0
  52. package/src/components-form.test.tsx +435 -0
  53. package/src/components-icons.test.tsx +197 -0
  54. package/src/components-keyboard.test.tsx +542 -2
  55. package/src/components-overlay.test.tsx +381 -0
  56. package/src/components-rtl.test.tsx +258 -0
  57. package/src/components-selection.test.tsx +207 -0
  58. package/src/components-table-scroll.test.tsx +363 -0
  59. package/src/index.ts +2 -0
  60. package/src/navigation-menu-focus.test.tsx +35 -0
  61. package/src/stories/Card.stories.tsx +81 -1
  62. package/src/stories/Checkbox.stories.tsx +23 -1
  63. package/src/stories/ContextMenu.stories.tsx +1 -1
  64. package/src/stories/Input.stories.tsx +34 -0
  65. package/src/stories/InputGroup.stories.tsx +96 -2
  66. package/src/stories/InputOTP.stories.tsx +24 -1
  67. package/src/stories/MultiSelect.stories.tsx +25 -1
  68. package/src/stories/NativeSelect.stories.tsx +24 -1
  69. package/src/stories/NumberInput.stories.tsx +20 -1
  70. package/src/stories/Progress.stories.tsx +1 -1
  71. package/src/stories/RadioGroup.stories.tsx +33 -1
  72. package/src/stories/SegmentedControl.stories.tsx +21 -1
  73. package/src/stories/Select.stories.tsx +28 -1
  74. package/src/stories/Slider.stories.tsx +1 -1
  75. package/src/stories/Switch.stories.tsx +37 -1
  76. package/src/stories/Table.stories.tsx +35 -1
  77. package/src/stories/Tabs.stories.tsx +27 -2
  78. package/src/stories/Textarea.stories.tsx +34 -0
  79. package/src/usage/icons.tsx +60 -0
  80. package/tailwind.config.ts +118 -22
@@ -3,12 +3,103 @@
3
3
  import * as React from "react";
4
4
  import { cn } from "../lib/utils";
5
5
 
6
+ /**
7
+ * How `TableCaption` and `Table` agree on the id that names the scroll container.
8
+ *
9
+ * `fallbackId` is a `useId` rather than a counter: stable across server and client render, and unique
10
+ * when several tables share a page. A caption given an explicit `id` by the caller keeps it.
11
+ *
12
+ * `register` is what keeps the two in step. The caption tells `Table` the id it actually ended up with,
13
+ * on mount, whenever that id changes, and again with `undefined` when it unmounts. The alternative —
14
+ * reading the caption's id out of the DOM while measuring — was wrong in a way review caught: a caller
15
+ * can change an `id` without changing any dimension (one derived from state, with the caption's text
16
+ * unchanged), no `ResizeObserver` callback fires, and the wrapper is left pointing `aria-labelledby` at
17
+ * an element that no longer exists. A dangling reference names nothing, so the focus stop goes
18
+ * unannounced. Registration follows the render that changed it, which is the thing that actually
19
+ * happened.
20
+ */
21
+ type TableCaptionRegistration = {
22
+ fallbackId: string;
23
+ register: (id: string | undefined) => void;
24
+ };
25
+
26
+ const TableCaptionContext = React.createContext<TableCaptionRegistration | undefined>(undefined);
27
+
28
+ /**
29
+ * True only while the scroll container can actually scroll horizontally.
30
+ *
31
+ * Deliberately conditional. `overflow-auto` makes this wrapper a scrollable region, and a scrollable
32
+ * region no keyboard can reach is unreachable without a pointer — WCAG 2.1.1, which axe reports as
33
+ * `scrollable-region-focusable`. But most tables fit, and an unconditional `tabIndex` would add a focus
34
+ * stop to every table in every consumer's app that lands on nothing to scroll. So the tab stop exists
35
+ * exactly when there is something to scroll.
36
+ *
37
+ * This mirrors the `useScrollable` hook the docs site already uses for its preview frames, down to the
38
+ * 1px tolerance; it is re-stated here rather than imported because `packages/ui` cannot depend on the
39
+ * site, and it is kept module-private rather than exported so this fix adds no public API.
40
+ *
41
+ * The 1px tolerance is not superstition: sub-pixel layout rounding makes `scrollWidth` exceed
42
+ * `clientWidth` by a fraction on tables that are not actually scrollable, and that fraction would
43
+ * otherwise mint a useless focus stop.
44
+ */
45
+ function useHorizontalScrollState(ref: React.RefObject<HTMLElement | null>) {
46
+ const [scrollable, setScrollable] = React.useState(false);
47
+
48
+ React.useEffect(() => {
49
+ const el = ref.current;
50
+ if (!el) return;
51
+
52
+ const measure = () => setScrollable(el.scrollWidth > el.clientWidth + 1);
53
+
54
+ measure();
55
+ // ResizeObserver, not polling: the wrapper changes size when the viewport does, and the table changes
56
+ // size when its content or the reader's text size does. Observing both covers a responsive layout, new
57
+ // rows arriving, and a font-size change, none of which fire a resize event on the window.
58
+ const observer = new ResizeObserver(measure);
59
+ observer.observe(el);
60
+ if (el.firstElementChild) observer.observe(el.firstElementChild);
61
+ return () => observer.disconnect();
62
+ }, [ref]);
63
+
64
+ return scrollable;
65
+ }
66
+
6
67
  const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
7
- ({ className, ...props }, ref) => (
8
- <div className="relative w-full overflow-auto">
9
- <table ref={ref} className={cn("w-full caption-bottom text-sm font-sans", className)} {...props} />
10
- </div>
11
- ),
68
+ ({ className, ...props }, ref) => {
69
+ const wrapper = React.useRef<HTMLDivElement>(null);
70
+ const fallbackId = React.useId();
71
+ const [labelledBy, setLabelledBy] = React.useState<string>();
72
+ const scrollable = useHorizontalScrollState(wrapper);
73
+ // `setLabelledBy` is stable, so this is created once per table and never re-provides needlessly.
74
+ const caption = React.useMemo<TableCaptionRegistration>(
75
+ () => ({ fallbackId, register: setLabelledBy }),
76
+ [fallbackId],
77
+ );
78
+
79
+ return (
80
+ <div
81
+ ref={wrapper}
82
+ // The focus ring is the existing one — `ring-ring` resolves to the same token every other control
83
+ // in this library focuses with. Left on unconditionally: a div with no `tabIndex` never matches
84
+ // `:focus-visible`, so this costs nothing while keeping the class string stable across a resize.
85
+ className="relative w-full overflow-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
86
+ {...(scrollable
87
+ ? {
88
+ tabIndex: 0,
89
+ // Named only when the table actually has a caption to be named by. `role="group"` rather than
90
+ // `region`, which is a landmark and would put one entry per table into the landmark list; and
91
+ // no fabricated fallback name, because "Scrollable table" on every table in the catalogue
92
+ // tells a screen-reader user nothing they could not already hear from the table itself.
93
+ ...(labelledBy ? { role: "group", "aria-labelledby": labelledBy } : {}),
94
+ }
95
+ : {})}
96
+ >
97
+ <TableCaptionContext.Provider value={caption}>
98
+ <table ref={ref} className={cn("w-full caption-bottom text-sm font-sans", className)} {...props} />
99
+ </TableCaptionContext.Provider>
100
+ </div>
101
+ );
102
+ },
12
103
  );
13
104
  Table.displayName = "Table";
14
105
 
@@ -61,9 +152,31 @@ const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<
61
152
  TableCell.displayName = "TableCell";
62
153
 
63
154
  const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
64
- ({ className, ...props }, ref) => (
65
- <caption ref={ref} className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
66
- ),
155
+ ({ className, id, ...props }, ref) => {
156
+ // Falls back to the id `Table` generated, so the scroll container has something real to point
157
+ // `aria-labelledby` at. A caller-supplied `id` always wins, so nothing that already sets one changes.
158
+ const context = React.useContext(TableCaptionContext);
159
+ const effectiveId = id ?? context?.fallbackId;
160
+ const register = context?.register;
161
+
162
+ // Report the id upward after every render that changes it, and withdraw it on unmount so a table
163
+ // whose caption is removed stops claiming a name instead of pointing at a missing element. A
164
+ // `TableCaption` rendered outside a `Table` has nothing to register with and simply renders.
165
+ React.useEffect(() => {
166
+ if (!register) return;
167
+ register(effectiveId);
168
+ return () => register(undefined);
169
+ }, [register, effectiveId]);
170
+
171
+ return (
172
+ <caption
173
+ ref={ref}
174
+ id={effectiveId}
175
+ className={cn("mt-4 text-sm text-muted-foreground", className)}
176
+ {...props}
177
+ />
178
+ );
179
+ },
67
180
  );
68
181
  TableCaption.displayName = "TableCaption";
69
182
 
@@ -4,6 +4,26 @@ import * as React from "react";
4
4
  import * as TabsPrimitive from "@radix-ui/react-tabs";
5
5
  import { cn } from "../lib/utils";
6
6
 
7
+ /**
8
+ * Tabs — one navigation group, following the navigation state contract (TOKENS.md, "Text entry and
9
+ * navigation") and the surface model:
10
+ *
11
+ * list an inset well, `--surface-grouped` — below a Card in both themes; at large text sizes the tabs
12
+ * wrap inside it rather than running off the page
13
+ * rest unselected tabs in `--muted-foreground`
14
+ * hover an unselected tab gets the selection controls' `--foreground` state layer at 8% and
15
+ * `--foreground` text
16
+ * selected a small raised surface on the well — `--card`, the Card's half-strength `--border` edge and
17
+ * `sm` depth — and `--foreground` text, so it is surface + edge + depth, never colour alone. The
18
+ * list used to be `--muted` with a `--background` tab, which in dark mode put the selected tab
19
+ * BELOW its well (measured: tab L 0.0031, well L 0.0167) — the inversion SegmentedControl had
20
+ * focus the 2px `--ring`, `!important` so the selected tab's edge cannot cover it; the selected
21
+ * surface stays under it
22
+ * disabled `--opacity-disabled`, inert
23
+ *
24
+ * There is no pressed state: Radix selects a tab on pointer-down, so pressing IS selecting.
25
+ * Motion: colour, background and box-shadow over `duration-fast` with `ease-standard`; nothing moves.
26
+ */
7
27
  const Tabs = TabsPrimitive.Root;
8
28
 
9
29
  const TabsList = React.forwardRef<
@@ -13,7 +33,10 @@ const TabsList = React.forwardRef<
13
33
  <TabsPrimitive.List
14
34
  ref={ref}
15
35
  className={cn(
16
- "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground font-sans",
36
+ // `min-h-9` and `flex-wrap`, not `h-9`: identical at the default text size, but at 200% text three
37
+ // tabs need 440px and a phone gives them 358 — the strip ran 82px off the page (large-text pass), and a
38
+ // fixed-height well would have clipped a second row. Now the tabs wrap inside the well and it grows.
39
+ "inline-flex min-h-9 max-w-full flex-wrap items-center justify-center rounded-lg bg-surface-grouped p-1 text-muted-foreground font-sans",
17
40
  className,
18
41
  )}
19
42
  {...props}
@@ -28,10 +51,12 @@ const TabsTrigger = React.forwardRef<
28
51
  <TabsPrimitive.Trigger
29
52
  ref={ref}
30
53
  className={cn(
31
- "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-all",
32
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
54
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium",
55
+ "transition-[color,background-color,box-shadow] duration-fast ease-standard",
56
+ "[@media(hover:hover)]:enabled:hover:data-[state=inactive]:bg-foreground/[0.08] [@media(hover:hover)]:enabled:hover:data-[state=inactive]:text-foreground",
57
+ "data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:[box-shadow:inset_0_0_0_1px_hsl(var(--border)/0.5),var(--shadow-sm)]",
58
+ "focus-visible:outline-none focus-visible:!ring-2 focus-visible:ring-ring",
33
59
  "disabled:pointer-events-none disabled:opacity-disabled",
34
- "data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
35
60
  className,
36
61
  )}
37
62
  {...props}
@@ -56,7 +56,7 @@ const Tag = React.forwardRef<HTMLSpanElement, TagProps>(
56
56
  type="button"
57
57
  onClick={onRemove}
58
58
  aria-label={name}
59
- className="-mr-0.5 ml-0.5 rounded-[2px] opacity-muted outline-none transition-opacity hover:opacity-visible focus-visible:ring-1 focus-visible:ring-current"
59
+ className="-me-0.5 ms-0.5 rounded-[2px] opacity-muted outline-none transition-opacity hover:opacity-visible focus-visible:ring-1 focus-visible:ring-current"
60
60
  >
61
61
  <X className="size-3.5" />
62
62
  </button>
@@ -9,14 +9,31 @@ import { cn } from "../lib/utils";
9
9
  * 01. Form Inputs › Textarea (node 54855:13857).
10
10
  *
11
11
  * Identical to `Input` except the field is multi-line: `min-h-[100px]`
12
- * (Figma `h: 100`) and vertical resize. Same state matrix and tokens.
12
+ * (Figma `h: 100`) and vertical resize. Same state matrix and tokens as Input:
13
+ *
14
+ * It follows the text-entry state contract (TOKENS.md, "Text entry and navigation"):
15
+ *
16
+ * rest edge `--muted-foreground` at 80% — 3.4:1 light / 5.4:1 dark on a card, where `--input` was
17
+ * 2.2:1 / 2.4:1 and was the only thing marking where the field is (SC 1.4.11)
18
+ * hover a pointer that can hover: the edge steps to full `--muted-foreground`. Not on a focused,
19
+ * invalid, read-only or disabled field — hover never takes over a stronger state
20
+ * focus `--action` edge + the `--shadow-focus` ring, the strongest signal, unchanged by the pointer
21
+ * invalid `aria-invalid="true"` → `--destructive` edge and ring, which hover does not replace; the
22
+ * field's error text is the non-colour cue (SC 1.4.1)
23
+ * read-only an inset `--muted` fill, full-strength text, no hover: readable and selectable, not editable
24
+ * disabled `--opacity-disabled`, inert
25
+ *
26
+ * Motion: border-color, box-shadow and background-color over `duration-instant` with `ease-standard`.
27
+ * Reduced motion collapses it (the library floor) and lands on the same end state.
13
28
  */
14
29
  const textareaVariants = cva(
15
30
  [
16
- "flex w-full min-h-[100px] resize-y rounded-sm border border-input bg-background px-3 py-3",
31
+ "flex w-full min-h-[100px] resize-y rounded-sm border border-muted-foreground/80 bg-background px-3 py-3",
17
32
  "font-sans text-body-md text-foreground",
18
33
  "placeholder:text-muted-foreground",
19
- "outline-none transition-colors",
34
+ "outline-none transition-[border-color,box-shadow,background-color] duration-instant ease-standard",
35
+ "[@media(hover:hover)]:enabled:hover:[&:not(:focus):not([aria-invalid=true]):not([readonly]):not([data-state=focus])]:border-muted-foreground",
36
+ "[&[readonly]]:bg-muted",
20
37
  "focus-visible:border-action focus-visible:shadow-focus",
21
38
  "disabled:cursor-not-allowed disabled:opacity-disabled",
22
39
  "aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:border-destructive aria-[invalid=true]:focus-visible:shadow-focus-destructive",
@@ -3,7 +3,9 @@
3
3
  import * as React from "react";
4
4
  import * as TooltipPrimitive from "@radix-ui/react-tooltip";
5
5
  import { cn } from "../lib/utils";
6
+ import { withDirectionalPortal } from "./direction-provider";
6
7
 
8
+ const TooltipPortal = withDirectionalPortal(TooltipPrimitive.Portal);
7
9
  const TooltipProvider = TooltipPrimitive.Provider;
8
10
  const Tooltip = TooltipPrimitive.Root;
9
11
  const TooltipTrigger = TooltipPrimitive.Trigger;
@@ -12,19 +14,27 @@ const TooltipContent = React.forwardRef<
12
14
  React.ElementRef<typeof TooltipPrimitive.Content>,
13
15
  React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
14
16
  >(({ className, sideOffset = 4, ...props }, ref) => (
15
- <TooltipPrimitive.Portal>
17
+ <TooltipPortal>
16
18
  <TooltipPrimitive.Content
17
19
  ref={ref}
18
20
  sideOffset={sideOffset}
19
21
  className={cn(
20
- "z-overlay overflow-hidden rounded-md bg-action px-3 py-1.5 text-xs text-action-foreground font-sans",
21
- "animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
22
+ // Capped to the room Radix reports it has, so large text cannot push the surface past the window —
23
+ // see the note in popover.tsx for the measurement that prompted it.
24
+ "z-overlay max-w-[var(--radix-popper-available-width)] overflow-hidden rounded-md bg-action px-3 py-1.5 text-xs text-action-foreground font-sans",
25
+ // Timing stays in the same string as the animation it times, so a reader (and the motion
26
+ // contract test) can see both at once.
27
+ "animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 duration-instant ease-enter data-[state=closed]:ease-exit",
28
+ // `data-side` is the side Radix RESOLVED the surface onto — it is already physical, having been
29
+ // flipped for direction and collisions before it reaches the DOM. The slide must therefore stay
30
+ // physical to travel away from the trigger; a logical class here would invert the animation under
31
+ // RTL and make the surface fly the wrong way. // rtl-ok
22
32
  "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
23
33
  className,
24
34
  )}
25
35
  {...props}
26
36
  />
27
- </TooltipPrimitive.Portal>
37
+ </TooltipPortal>
28
38
  ));
29
39
  TooltipContent.displayName = TooltipPrimitive.Content.displayName;
30
40
 
@@ -3,6 +3,7 @@
3
3
  import * as React from "react";
4
4
  import { createPortal } from "react-dom";
5
5
  import { Button } from "./button";
6
+ import { useKinetixPortalContainer } from "./direction-provider";
6
7
 
7
8
  /**
8
9
  * Tour — sequenced spotlight popovers over real elements (onboarding
@@ -38,6 +39,13 @@ export interface TourProps {
38
39
 
39
40
  const SPOTLIGHT_PADDING = 8;
40
41
 
42
+ /** `true` when the user asked for less motion. Safe on the server and anywhere `matchMedia` is absent. */
43
+ function prefersReducedMotion(): boolean {
44
+ return typeof window !== "undefined" && typeof window.matchMedia === "function"
45
+ ? window.matchMedia("(prefers-reduced-motion: reduce)").matches
46
+ : false;
47
+ }
48
+
41
49
  const Tour: React.FC<TourProps> = ({
42
50
  steps,
43
51
  open,
@@ -50,6 +58,9 @@ const Tour: React.FC<TourProps> = ({
50
58
  doneLabel = "Done",
51
59
  }) => {
52
60
  const [mounted, setMounted] = React.useState(false);
61
+ // Portaled like every other overlay here: into the nearest direction provider's host, else <body>. `null`
62
+ // is a provider whose host is not attached yet: render nothing until it is.
63
+ const portalHost = useKinetixPortalContainer();
53
64
  const [rect, setRect] = React.useState<DOMRect | null>(null);
54
65
  const step = steps[stepIndex];
55
66
 
@@ -98,7 +109,11 @@ const Tour: React.FC<TourProps> = ({
98
109
  const el = document.querySelector(step.target);
99
110
  if (el) {
100
111
  setRect(el.getBoundingClientRect());
101
- el.scrollIntoView({ behavior: "smooth", block: "center" });
112
+ // An explicit `behavior: "smooth"` overrides the CSS `scroll-behavior: auto` that the
113
+ // reduced-motion base layer sets, so the option has to be chosen rather than inherited:
114
+ // otherwise the one motion a reduced-motion user cannot look away from — the viewport
115
+ // itself travelling — is the one that still plays.
116
+ el.scrollIntoView({ behavior: prefersReducedMotion() ? "auto" : "smooth", block: "center" });
102
117
  } else {
103
118
  setRect(null);
104
119
  }
@@ -121,7 +136,7 @@ const Tour: React.FC<TourProps> = ({
121
136
  return () => document.removeEventListener("keydown", onKeyDown);
122
137
  }, [open, onOpenChange]);
123
138
 
124
- if (!mounted || !open || !step) return null;
139
+ if (!mounted || !open || !step || portalHost === null) return null;
125
140
 
126
141
  const isFirst = stepIndex === 0;
127
142
  const isLast = stepIndex === steps.length - 1;
@@ -132,9 +147,9 @@ const Tour: React.FC<TourProps> = ({
132
147
  left: rect.left - SPOTLIGHT_PADDING,
133
148
  width: rect.width + SPOTLIGHT_PADDING * 2,
134
149
  height: rect.height + SPOTLIGHT_PADDING * 2,
135
- boxShadow: "0 0 0 9999px rgb(0 0 0 / 0.6)",
150
+ boxShadow: "0 0 0 9999px hsl(var(--scrim))",
136
151
  }
137
- : { inset: 0, background: "rgb(0 0 0 / 0.6)" };
152
+ : { inset: 0, background: "hsl(var(--scrim))" };
138
153
 
139
154
  const cardStyle: React.CSSProperties = rect
140
155
  ? {
@@ -147,7 +162,7 @@ const Tour: React.FC<TourProps> = ({
147
162
  <>
148
163
  <div
149
164
  aria-hidden
150
- className="pointer-events-none fixed z-overlay rounded-lg transition-all duration-fast"
165
+ className="pointer-events-none fixed z-overlay rounded-lg transition-[top,left,width,height,opacity] duration-fast ease-standard"
151
166
  style={spotlightStyle}
152
167
  />
153
168
  <div
@@ -157,7 +172,7 @@ const Tour: React.FC<TourProps> = ({
157
172
  aria-labelledby={titleId}
158
173
  tabIndex={-1}
159
174
  onKeyDown={trapTab}
160
- className="fixed z-overlay w-80 rounded-lg border border-border bg-popover p-4 font-sans text-popover-foreground shadow-lg outline-none transition-all duration-fast"
175
+ className="fixed z-overlay w-80 rounded-lg border border-border bg-popover p-4 font-sans text-popover-foreground shadow-lg outline-none transition-[top,left,opacity] duration-fast ease-standard"
161
176
  style={cardStyle}
162
177
  >
163
178
  <p id={titleId} className="text-title-sm font-medium text-foreground">
@@ -192,7 +207,7 @@ const Tour: React.FC<TourProps> = ({
192
207
  </div>
193
208
  </div>
194
209
  </>,
195
- document.body,
210
+ portalHost ?? document.body,
196
211
  );
197
212
  };
198
213
 
@@ -262,7 +262,7 @@ const TreeItem = React.forwardRef<HTMLDivElement, TreeItemProps>(
262
262
  >
263
263
  {hasChildren ? (
264
264
  <ChevronRight
265
- className={cn("size-4 shrink-0 text-muted-foreground transition-transform", expanded && "rotate-90")}
265
+ className={cn("size-4 shrink-0 text-muted-foreground transition-transform", expanded ? "rotate-90" : "rtl:-scale-x-100")}
266
266
  onClick={(e) => {
267
267
  e.stopPropagation();
268
268
  ctx.toggleExpanded(value);
@@ -12,6 +12,7 @@ import {
12
12
  AccordionItem,
13
13
  AccordionTrigger,
14
14
  } from "./components/accordion";
15
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./components/collapsible";
15
16
 
16
17
  // kx-verify: interaction
17
18
 
@@ -137,3 +138,76 @@ describe("Accordion", () => {
137
138
  expect(screen.getByText("Answer text")).toBeVisible();
138
139
  });
139
140
  });
141
+
142
+ /**
143
+ * Collapsible's disclosure contract.
144
+ *
145
+ * These assert the behaviour and the structural prerequisites for the transition — that the content
146
+ * is clipped while its height animates, and that it carries the state attribute the animation is
147
+ * selected on. They deliberately do NOT assert that it moved: jsdom has no layout and no compositor,
148
+ * so a test here cannot tell a 200ms height animation from an instant one. That claim is made by
149
+ * `scripts/motion.mjs`, in a browser, by reading a rendered midpoint.
150
+ *
151
+ * The split matters because the component shipped with no tests at all and no motion, and a test
152
+ * asserting a class name would have gone on passing if the keyframes were deleted.
153
+ */
154
+ describe("Collapsible", () => {
155
+ const Example = () => (
156
+ <Collapsible>
157
+ <CollapsibleTrigger>Toggle</CollapsibleTrigger>
158
+ <CollapsibleContent>Hidden detail</CollapsibleContent>
159
+ </Collapsible>
160
+ );
161
+
162
+ it("reveals and hides its content from the trigger", async () => {
163
+ render(<Example />);
164
+ expect(screen.queryByText("Hidden detail")).not.toBeInTheDocument();
165
+ await userEvent.click(screen.getByRole("button", { name: "Toggle" }));
166
+ expect(screen.getByText("Hidden detail")).toBeVisible();
167
+ await userEvent.click(screen.getByRole("button", { name: "Toggle" }));
168
+ expect(screen.queryByText("Hidden detail")).not.toBeInTheDocument();
169
+ });
170
+
171
+ it("opens from the keyboard, because the trigger is a real button", async () => {
172
+ render(<Example />);
173
+ await userEvent.tab();
174
+ expect(screen.getByRole("button", { name: "Toggle" })).toHaveFocus();
175
+ await userEvent.keyboard("{Enter}");
176
+ expect(screen.getByText("Hidden detail")).toBeVisible();
177
+ await userEvent.keyboard(" ");
178
+ expect(screen.queryByText("Hidden detail")).not.toBeInTheDocument();
179
+ });
180
+
181
+ it("clips its content while the height animates", async () => {
182
+ render(<Example />);
183
+ await userEvent.click(screen.getByRole("button", { name: "Toggle" }));
184
+ // Without overflow-hidden the text is laid out at full height from the first frame and spills
185
+ // out of the box the animation is still collapsing.
186
+ //
187
+ // `getByText` returns the content element itself: unlike AccordionContent, this component wraps
188
+ // its children in nothing, so the animated element and the text's element are the same node.
189
+ expect(screen.getByText("Hidden detail")).toHaveClass("overflow-hidden");
190
+ });
191
+
192
+ it("carries the state attribute the disclosure animation is selected on", async () => {
193
+ render(<Example />);
194
+ const trigger = screen.getByRole("button", { name: "Toggle" });
195
+ await userEvent.click(trigger);
196
+ const content = screen.getByText("Hidden detail");
197
+ expect(content).toHaveAttribute("data-state", "open");
198
+ expect(content?.className).toContain("data-[state=open]:animate-collapsible-down");
199
+ expect(content?.className).toContain("data-[state=closed]:animate-collapsible-up");
200
+ });
201
+
202
+ it("merges a caller's className instead of dropping it", async () => {
203
+ render(
204
+ <Collapsible defaultOpen>
205
+ <CollapsibleTrigger>Toggle</CollapsibleTrigger>
206
+ <CollapsibleContent className="mt-4">Hidden detail</CollapsibleContent>
207
+ </Collapsible>,
208
+ );
209
+ const content = screen.getByText("Hidden detail");
210
+ expect(content).toHaveClass("mt-4");
211
+ expect(content).toHaveClass("overflow-hidden");
212
+ });
213
+ });