@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.
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
@@ -8,6 +8,34 @@ export interface KinetixDirectionProviderProps {
8
8
  dir: "ltr" | "rtl";
9
9
  }
10
10
 
11
+ /**
12
+ * Where this provider's overlays portal to: a `<div dir>` directly under `<body>`, one per provider.
13
+ *
14
+ * Overlays (Dialog, Sheet, Popover, Tooltip, menus, …) portal out of the tree that rendered them, so the
15
+ * `dir` attribute around the trigger never reaches them — they inherit from `<body>`, which inherits from
16
+ * `<html>`. Radix's `DirectionContext` fixes its own positioning and, for Select and the menu family, stamps
17
+ * `dir` on the content, but Dialog, Popover, Tooltip and the rest stamp nothing, so their text, logical
18
+ * padding and `rtl:` variants followed the document instead of the section that opened them.
19
+ *
20
+ * Portaling into a host that carries the provider's `dir` fixes all of them in one place, through ordinary
21
+ * DOM inheritance: `direction`, `[dir]` selectors and Tailwind's `rtl:` variant all match an ancestor. The
22
+ * host is a plain block with no styles, so it creates no stacking context or containing block — fixed
23
+ * overlays position against the viewport exactly as they did from `<body>`.
24
+ *
25
+ * Three states, so nothing is ever committed into a host that is not in the document:
26
+ * `undefined` no provider: overlays portal to `<body>` and inherit `<html dir>`, as before.
27
+ * `null` a provider whose host is not attached yet (its first render, and the server): portals render
28
+ * nothing. Descendants' layout effects run before the provider's, so publishing the host
29
+ * earlier would let an overlay that mounts with the provider (an open Dialog, say) commit its DOM
30
+ * into a detached node, where native `autoFocus` and layout measurements are lost.
31
+ * an element attached: portals render into it.
32
+ * The switch from `null` to the element happens in the provider's layout effect, so it is applied before the
33
+ * browser paints.
34
+ */
35
+ const PortalContainerContext = React.createContext<HTMLElement | null | undefined>(undefined);
36
+
37
+ const useIsomorphicLayoutEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
38
+
11
39
  /**
12
40
  * Every Radix primitive this library builds on (Select, DropdownMenu, Popover,
13
41
  * Tooltip, ContextMenu, Menubar, NavigationMenu, HoverCard, …) reads direction
@@ -21,7 +49,63 @@ export interface KinetixDirectionProviderProps {
21
49
  * <html dir={dir}>
22
50
  * <KinetixDirectionProvider dir={dir}>{children}</KinetixDirectionProvider>
23
51
  * </html>
52
+ *
53
+ * It also scopes: a provider around one section (with `dir` on that section's element) gives that section
54
+ * its own direction, and the overlays opened from inside it portal with that direction too, without
55
+ * touching `<html>`. The nearest provider wins.
24
56
  */
25
57
  export function KinetixDirectionProvider({ dir, children }: KinetixDirectionProviderProps) {
26
- return <RadixDirectionProvider dir={dir}>{children}</RadixDirectionProvider>;
58
+ // Created on the client's first render, attached in a layout effect.
59
+ const [host] = React.useState(() => (typeof document === "undefined" ? null : document.createElement("div")));
60
+ const [attached, setAttached] = React.useState(false);
61
+
62
+ useIsomorphicLayoutEffect(() => {
63
+ if (!host) return;
64
+ host.setAttribute("data-kinetix-portal", "");
65
+ host.setAttribute("dir", dir);
66
+ document.body.appendChild(host);
67
+ setAttached(true);
68
+ return () => {
69
+ host.remove();
70
+ setAttached(false);
71
+ };
72
+ // `dir` is applied by the effect below; this one only attaches, once.
73
+ }, [host]);
74
+
75
+ useIsomorphicLayoutEffect(() => {
76
+ host?.setAttribute("dir", dir);
77
+ }, [host, dir]);
78
+
79
+ return (
80
+ <RadixDirectionProvider dir={dir}>
81
+ <PortalContainerContext.Provider value={attached ? host : null}>{children}</PortalContainerContext.Provider>
82
+ </RadixDirectionProvider>
83
+ );
84
+ }
85
+
86
+ /**
87
+ * The element the nearest `KinetixDirectionProvider` wants overlays portaled into. `undefined` outside one
88
+ * (portal to `<body>` as usual); `null` inside one whose host is not attached yet, in which case render
89
+ * nothing and try again, which happens before paint. Every overlay in this library follows this; use it for a
90
+ * custom portal so it gets the same direction.
91
+ */
92
+ export function useKinetixPortalContainer(): HTMLElement | null | undefined {
93
+ return React.useContext(PortalContainerContext);
94
+ }
95
+
96
+ /**
97
+ * Wraps a primitive's `Portal` so it portals into the nearest provider's host (see above). An explicit
98
+ * `container` still wins. Internal: every overlay in this library is built with it, which is what keeps the
99
+ * direction rule in one place instead of in each overlay.
100
+ */
101
+ export function withDirectionalPortal<P extends { container?: Element | DocumentFragment | null }>(
102
+ Portal: React.ComponentType<P>,
103
+ ) {
104
+ function DirectionalPortal({ container, ...props }: P) {
105
+ const host = useKinetixPortalContainer();
106
+ if (host === null && !container) return null;
107
+ return <Portal {...(props as unknown as P)} container={container ?? host} />;
108
+ }
109
+ DirectionalPortal.displayName = `Directional(${Portal.displayName ?? Portal.name ?? "Portal"})`;
110
+ return DirectionalPortal;
27
111
  }
@@ -3,6 +3,7 @@
3
3
  import * as React from "react";
4
4
  import { Drawer as DrawerPrimitive } from "vaul";
5
5
  import { cn } from "../lib/utils";
6
+ import { withDirectionalPortal } from "./direction-provider";
6
7
 
7
8
  const Drawer = ({ shouldScaleBackground = true, ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
8
9
  <DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
@@ -10,36 +11,79 @@ const Drawer = ({ shouldScaleBackground = true, ...props }: React.ComponentProps
10
11
  Drawer.displayName = "Drawer";
11
12
 
12
13
  const DrawerTrigger = DrawerPrimitive.Trigger;
13
- const DrawerPortal = DrawerPrimitive.Portal;
14
+ const DrawerPortal = withDirectionalPortal(DrawerPrimitive.Portal);
14
15
  const DrawerClose = DrawerPrimitive.Close;
15
16
 
16
17
  const DrawerOverlay = React.forwardRef<
17
18
  React.ElementRef<typeof DrawerPrimitive.Overlay>,
18
19
  React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
19
20
  >(({ className, ...props }, ref) => (
20
- <DrawerPrimitive.Overlay ref={ref} className={cn("fixed inset-0 z-overlay bg-foreground/40", className)} {...props} />
21
+ <DrawerPrimitive.Overlay ref={ref} className={cn("fixed inset-0 z-overlay bg-scrim", className)} {...props} />
21
22
  ));
22
23
  DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
23
24
 
24
25
  const DrawerContent = React.forwardRef<
25
26
  React.ElementRef<typeof DrawerPrimitive.Content>,
26
27
  React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
27
- >(({ className, children, ...props }, ref) => (
28
- <DrawerPortal>
29
- <DrawerOverlay />
30
- <DrawerPrimitive.Content
31
- ref={ref}
32
- className={cn(
33
- "fixed inset-x-0 bottom-0 z-overlay mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background font-sans",
34
- className,
35
- )}
36
- {...props}
37
- >
38
- <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
39
- {children}
40
- </DrawerPrimitive.Content>
41
- </DrawerPortal>
42
- ));
28
+ >(({ className, children, onOpenAutoFocus, ...props }, ref) => {
29
+ /**
30
+ * Focus entry. Measured in Chromium with the drawer open: focus was still on the trigger — which by
31
+ * then sits inside a subtree `aria-hidden="true"`, because the drawer hides the page behind it. So a
32
+ * screen-reader user opened the drawer and was left on an element their assistive technology had just
33
+ * been told does not exist. Tab recovered on the next keystroke; the destination on open did not.
34
+ *
35
+ * Every other modal surface in the family moves focus into the portal on open. `vaul` suppresses that
36
+ * deliberately, to stop a mobile keyboard appearing whenever a drawer with a field in it opens, and
37
+ * that reason is good — so this focuses the panel itself rather than the first thing inside it. A
38
+ * container is not a text field: it opens no keyboard, it gives the screen reader the drawer's own
39
+ * heading to announce, and Tab from there walks the drawer's contents in order.
40
+ *
41
+ * Notably, axe does *not* report this. Its `aria-hidden-focus` rule excuses an aria-hidden page
42
+ * whenever a modal dialog is open, wherever focus actually is — so the open-state scan that found the
43
+ * Popover naming defect is blind to this one. Focus entry needs its own assertion, which is why
44
+ * a11y-browser now carries one.
45
+ */
46
+ const panel = React.useRef<HTMLDivElement | null>(null);
47
+ const setRefs = (node: HTMLDivElement | null) => {
48
+ panel.current = node;
49
+ if (typeof ref === "function") ref(node);
50
+ else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
51
+ };
52
+
53
+ return (
54
+ <DrawerPortal>
55
+ <DrawerOverlay />
56
+ <DrawerPrimitive.Content
57
+ ref={setRefs}
58
+ tabIndex={-1}
59
+ // `onOpenAutoFocus` and not a mount effect: the focus scope underneath runs after mount, so an
60
+ // effect that focuses the panel is overwritten a tick later and the fix silently does nothing
61
+ // (measured — the first attempt here was exactly that). This is the moment the primitive offers.
62
+ //
63
+ // Composed with the caller's handler and destructured out of the spread below: with `{...props}`
64
+ // trailing, any `onOpenAutoFocus` a consumer passes — even one that only logs — would replace this
65
+ // one outright, and since vaul suppresses auto-focus otherwise, focus would be left on the
66
+ // now-hidden trigger and the defect would be back. A caller who calls `preventDefault` themselves
67
+ // is taking focus over deliberately, so that decision stands and the panel is left alone.
68
+ onOpenAutoFocus={(event) => {
69
+ onOpenAutoFocus?.(event);
70
+ if (event.defaultPrevented) return;
71
+ event.preventDefault();
72
+ panel.current?.focus();
73
+ }}
74
+ className={cn(
75
+ "fixed inset-x-0 bottom-0 z-overlay mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background font-sans",
76
+ "outline-none",
77
+ className,
78
+ )}
79
+ {...props}
80
+ >
81
+ <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
82
+ {children}
83
+ </DrawerPrimitive.Content>
84
+ </DrawerPortal>
85
+ );
86
+ });
43
87
  DrawerContent.displayName = "DrawerContent";
44
88
 
45
89
  const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
@@ -4,16 +4,24 @@ import * as React from "react";
4
4
  import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
5
5
  import { Check, ChevronRight, Circle } from "lucide-react";
6
6
  import { cn } from "../lib/utils";
7
+ import { withDirectionalPortal } from "./direction-provider";
7
8
 
8
9
  const DropdownMenu = DropdownMenuPrimitive.Root;
9
10
  const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
10
11
  const DropdownMenuGroup = DropdownMenuPrimitive.Group;
11
- const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
12
+ const DropdownMenuPortal = withDirectionalPortal(DropdownMenuPrimitive.Portal);
12
13
  const DropdownMenuSub = DropdownMenuPrimitive.Sub;
13
14
  const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
14
15
 
15
16
  const contentCls =
16
- "z-overlay min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md font-sans data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95";
17
+ // Capped to the room Radix reports it has, so large text cannot push the surface past the window —
18
+ // see the note in popover.tsx for the measurement that prompted it.
19
+ // `min-w` yields to the available room, which is not a detail: `min-width` beats `max-width` in CSS,
20
+ // so the cap above was unenforceable on its own. Measured on a 390px phone with the root font size
21
+ // doubled — 8rem becomes 256px, the cap resolved to 193px, and the menu came out 256px wide with its
22
+ // right edge 63px past the window and those items unreachable. `min(...)` keeps the comfortable
23
+ // minimum whenever there is room for it and gives it up when there is not.
24
+ "z-overlay max-w-[var(--radix-popper-available-width)] min-w-[min(8rem,var(--radix-popper-available-width))] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md font-sans data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=open]:duration-fast data-[state=open]:ease-enter data-[state=closed]:duration-instant data-[state=closed]:ease-exit";
17
25
  const itemCls =
18
26
  "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground focus:ring-1 focus:ring-inset focus:ring-ring data-[disabled]:pointer-events-none data-[disabled]:opacity-disabled";
19
27
 
@@ -21,9 +29,9 @@ const DropdownMenuContent = React.forwardRef<
21
29
  React.ElementRef<typeof DropdownMenuPrimitive.Content>,
22
30
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
23
31
  >(({ className, sideOffset = 4, ...props }, ref) => (
24
- <DropdownMenuPrimitive.Portal>
32
+ <DropdownMenuPortal>
25
33
  <DropdownMenuPrimitive.Content ref={ref} sideOffset={sideOffset} className={cn(contentCls, className)} {...props} />
26
- </DropdownMenuPrimitive.Portal>
34
+ </DropdownMenuPortal>
27
35
  ));
28
36
  DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
29
37
 
@@ -139,7 +139,7 @@ function FileUploadItem({ file, onRemove, onRetry }: FileUploadItemProps) {
139
139
  return (
140
140
  <li className={cn("flex items-center gap-2.5 rounded-md border p-2.5 text-body-sm", isError ? "border-destructive bg-destructive/5" : "border-input")}>
141
141
  {isLoading ? (
142
- <Loader2 className="size-4 shrink-0 animate-spin motion-reduce:animate-[spin_3s_linear_infinite] text-muted-foreground" aria-hidden />
142
+ <Loader2 className="size-4 shrink-0 animate-spin motion-reduce:animate-none text-muted-foreground" aria-hidden />
143
143
  ) : isError ? (
144
144
  <TriangleAlert className="size-4 shrink-0 text-destructive" aria-hidden />
145
145
  ) : (
@@ -17,7 +17,7 @@ const HoverCardContent = React.forwardRef<
17
17
  sideOffset={sideOffset}
18
18
  className={cn(
19
19
  "z-overlay w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none font-sans",
20
- "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
20
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=open]:duration-fast data-[state=open]:ease-enter data-[state=closed]:duration-instant data-[state=closed]:ease-exit",
21
21
  className,
22
22
  )}
23
23
  {...props}
@@ -40,12 +40,18 @@ export interface InformProps
40
40
  VariantProps<typeof informVariants> {
41
41
  hideIcon?: boolean;
42
42
  onDismiss?: () => void;
43
+ /**
44
+ * Replaces the default dismiss mark (lucide's `X`) with your own icon — any icon library or SVG.
45
+ * The button keeps its accessible name ("Dismiss") and sizes any SVG inside it to 14px, so the icon
46
+ * needs no size, colour or aria attributes of its own. See /docs/icons.
47
+ */
48
+ dismissIcon?: React.ReactNode;
43
49
  /** optional CTA rendered below the message */
44
50
  action?: { label: React.ReactNode; onClick?: () => void; icon?: React.ReactNode };
45
51
  }
46
52
 
47
53
  const Inform = React.forwardRef<HTMLDivElement, InformProps>(
48
- ({ className, variant = "information", hideIcon, onDismiss, action, children, ...props }, ref) => {
54
+ ({ className, variant = "information", hideIcon, onDismiss, dismissIcon, action, children, ...props }, ref) => {
49
55
  const Icon = ICON[variant ?? "information"];
50
56
  return (
51
57
  <div ref={ref} role="status" className={cn(informVariants({ variant }), className)} {...props}>
@@ -68,9 +74,9 @@ const Inform = React.forwardRef<HTMLDivElement, InformProps>(
68
74
  type="button"
69
75
  onClick={onDismiss}
70
76
  aria-label="Dismiss"
71
- className="-m-0.5 shrink-0 rounded-[2px] p-0.5 opacity-muted outline-none transition-opacity hover:opacity-visible focus-visible:opacity-visible focus-visible:ring-1 focus-visible:ring-current"
77
+ className="-m-0.5 shrink-0 rounded-[2px] p-0.5 opacity-muted outline-none transition-opacity hover:opacity-visible focus-visible:opacity-visible focus-visible:ring-1 focus-visible:ring-current [&_svg]:size-3.5 [&_svg]:shrink-0"
72
78
  >
73
- <X className="size-3.5" />
79
+ {dismissIcon ?? <X className="size-3.5" />}
74
80
  </button>
75
81
  )}
76
82
  </div>
@@ -10,6 +10,18 @@ import { cn } from "../lib/utils";
10
10
  * source's "Fixed Add-on" pattern. Border / focus glow / invalid state apply to
11
11
  * the whole group.
12
12
  *
13
+ * The shell follows the text-entry state contract (TOKENS.md, "Composite fields"): it is ONE field, so it
14
+ * carries the field's edge, hover, focus, invalid, read-only and disabled states, read from the input inside
15
+ * it. Add-ons draw no border of their own except an `InputGroupButton`'s quiet internal divider.
16
+ *
17
+ * rest edge `--muted-foreground` at 80% (3:1 on a card; `--input` was 2.2:1)
18
+ * hover the edge steps to full `--muted-foreground` — never over focus, invalid, read-only or disabled
19
+ * focus the INPUT focused → `--action` edge + `--shadow-focus` on the shell. An add-on button focused
20
+ * → that button's own inset ring, and the shell stays at rest: the ring says which part has focus
21
+ * invalid `aria-invalid="true"` on the input → `--destructive` edge, kept under the pointer and focus
22
+ * read-only `readonly` on the input → the inset `--muted` fill
23
+ * disabled a disabled input → `--opacity-disabled`, inert
24
+ *
13
25
  * <InputGroup>
14
26
  * <InputGroupText>https://</InputGroupText>
15
27
  * <InputGroupInput placeholder="kinetixui.com" />
@@ -21,9 +33,12 @@ const InputGroup = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
21
33
  ref={ref}
22
34
  data-slot="input-group"
23
35
  className={cn(
24
- "flex w-full items-center overflow-hidden rounded-sm border border-input bg-background font-sans transition-colors",
25
- "focus-within:border-action focus-within:shadow-focus",
26
- "has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:focus-within:shadow-focus-destructive",
36
+ "flex w-full items-center overflow-hidden rounded-sm border border-muted-foreground/80 bg-background font-sans",
37
+ "transition-[border-color,box-shadow,background-color] duration-instant ease-standard",
38
+ "[@media(hover:hover)]:hover:[&:not(:has([data-slot=input-group-input]:is(:focus,:disabled,[readonly],[aria-invalid=true])))]:border-muted-foreground",
39
+ "has-[[data-slot=input-group-input][readonly]]:bg-muted",
40
+ "has-[[data-slot=input-group-input]:focus:not([aria-invalid=true])]:border-action has-[[data-slot=input-group-input]:focus]:shadow-focus",
41
+ "has-[[aria-invalid=true]]:border-destructive has-[[aria-invalid=true]]:has-[[data-slot=input-group-input]:focus]:shadow-focus-destructive",
27
42
  "has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-disabled",
28
43
  className,
29
44
  )}
@@ -83,9 +98,16 @@ const InputGroupButton = React.forwardRef<
83
98
  ref={ref}
84
99
  type={type}
85
100
  className={cn(
101
+ // The divider is internal structure, deliberately quieter than the shell's edge: the shell is the one
102
+ // boundary of the field, the divider only says where the button begins.
86
103
  "flex h-full shrink-0 items-center gap-1.5 self-stretch border-s border-input bg-muted px-3 text-label-md text-foreground",
87
- // base is bg-muted and muted==accent on dark, so the fill is a no-op there — the ring carries it
88
- "outline-none transition-colors hover:bg-accent hover:ring-1 hover:ring-inset hover:ring-ring focus-visible:bg-accent focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-disabled",
104
+ // Hover is the selection controls' `foreground` state layer (8%), which reads on `muted` in both themes —
105
+ // `accent` equals `muted` in dark, so an accent fill changed nothing there. Focus is the inset ring, and
106
+ // only focus draws it: a ring on hover made the pointer look exactly like keyboard focus.
107
+ "outline-none transition-[background-color,box-shadow] duration-instant ease-standard",
108
+ // (an inset shadow, so it layers over `muted` and composes with the ring instead of replacing either)
109
+ "[@media(hover:hover)]:enabled:hover:shadow-[inset_0_0_0_100vmax_hsl(var(--foreground)/0.08)]",
110
+ "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-disabled",
89
111
  "[&_svg]:size-4 [&_svg]:shrink-0",
90
112
  className,
91
113
  )}
@@ -11,7 +11,9 @@ const InputOTP = React.forwardRef<
11
11
  >(({ className, containerClassName, ...props }, ref) => (
12
12
  <OTPInput
13
13
  ref={ref}
14
- containerClassName={cn("flex items-center gap-2 has-[:disabled]:opacity-disabled", containerClassName)}
14
+ // `group/otp`: the slots read the field's state from the one real input inside this container — see
15
+ // InputOTPSlot.
16
+ containerClassName={cn("group/otp flex flex-wrap items-center gap-2 has-[:disabled]:opacity-disabled", containerClassName)}
15
17
  className={cn("disabled:cursor-not-allowed", className)}
16
18
  {...props}
17
19
  />
@@ -19,7 +21,17 @@ const InputOTP = React.forwardRef<
19
21
  InputOTP.displayName = "InputOTP";
20
22
 
21
23
  const InputOTPGroup = React.forwardRef<React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div">>(
22
- ({ className, ...props }, ref) => <div ref={ref} className={cn("flex items-center", className)} {...props} />,
24
+ ({ className, ...props }, ref) => (
25
+ // `flex-wrap` costs nothing at a normal text size — six 36px slots fit the narrowest phone with room
26
+ // to spare — and rescues the case that measured badly: at 2x the default font size on a 390px
27
+ // viewport the slots are 72px each, the row is 432px, and the page gained 106px of horizontal
28
+ // scrolling. A second row of slots is worse-looking than one; a form the reader has to scroll
29
+ // sideways to type a code into is worse than that.
30
+ //
31
+ // Wrapping is only safe because each slot owns its whole border — see InputOTPSlot. A row that
32
+ // borrowed its first edge from `:first-child` would be open at the end it began from.
33
+ <div ref={ref} className={cn("flex flex-wrap items-center", className)} {...props} />
34
+ ),
23
35
  );
24
36
  InputOTPGroup.displayName = "InputOTPGroup";
25
37
 
@@ -34,17 +46,48 @@ const InputOTPSlot = React.forwardRef<
34
46
  <div
35
47
  ref={ref}
36
48
  className={cn(
37
- "relative flex size-9 items-center justify-center border-y border-r border-input text-sm shadow-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
49
+ // Every slot carries its own border, and each one after the first is pulled back by a pixel so the
50
+ // two borders between neighbours collapse into the single divider the design has always shown.
51
+ //
52
+ // The obvious cheaper spelling — block borders on all, an inline-end divider, and the inline-start
53
+ // edge on `:first-child` — is what this replaces, because `:first-child` is the first slot in the
54
+ // DOM and not the first slot in each visual row. Measured at 390px and 2x text, where six slots
55
+ // wrap: the second row began with `border-s: 0`, so its outer start edge was open in LTR and in
56
+ // RTL alike. That is the same missing-outline defect the logical classes were added to fix, and it
57
+ // came back the moment the row could wrap. With a border on every side of every slot, a row is
58
+ // closed wherever it starts and wherever it ends.
59
+ //
60
+ // Two consequences, both deliberate: the rounded corners still belong to the DOM's first and last
61
+ // slot, so a wrapped row has square outer corners; and two wrapped rows meet in a 2px seam, since
62
+ // no selector can pull a row that only exists at one viewport width. A seam is not an open edge.
63
+ //
64
+ // The slots together are one field and follow the text-entry state contract (TOKENS.md, "Composite
65
+ // fields"), reading its state from the hidden input through `group/otp`:
66
+ // rest edge `--muted-foreground` at 80% — 3:1 where `--input` was 2.2:1 — and no elevation: a
67
+ // field is inset, so the per-slot `shadow-sm` is gone
68
+ // hover every slot's edge steps to full `--muted-foreground`; not while focused, invalid or disabled
69
+ // focus the ACTIVE slot (where the next character goes) takes `--action` + `--shadow-focus`, the
70
+ // strongest state, drawn above its neighbours
71
+ // invalid `aria-invalid="true"` on InputOTP → `--destructive` edges, kept under pointer and focus
72
+ // disabled the container's `--opacity-disabled`; no hover
73
+ "relative flex size-9 items-center justify-center border border-muted-foreground/80 bg-background text-sm",
74
+ "[&:not(:first-child)]:-ms-px first:rounded-s-md last:rounded-e-md",
75
+ "transition-[color,border-color,box-shadow] duration-instant ease-standard",
76
+ "[@media(hover:hover)]:group-[:hover:not(:has(input:is(:focus,:disabled,[aria-invalid=true])))]/otp:border-muted-foreground",
77
+ "group-has-[input[aria-invalid=true]]/otp:border-destructive",
38
78
  // ring is a box-shadow, which forced-colors mode strips; the outline survives it
39
- isActive && "z-docked ring-1 ring-ring forced-colors:[outline:2px_solid] forced-colors:[outline-offset:-2px]",
79
+ isActive &&
80
+ "z-docked border-action shadow-focus group-has-[input[aria-invalid=true]]/otp:shadow-focus-destructive forced-colors:[outline:2px_solid] forced-colors:[outline-offset:-2px]",
40
81
  className,
41
82
  )}
83
+ // A stable hook for the active slot, so tests and gates don't have to read its styling.
84
+ data-active={isActive ? "" : undefined}
42
85
  {...props}
43
86
  >
44
87
  {char}
45
88
  {hasFakeCaret && (
46
89
  <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
47
- <div className="h-4 w-px animate-caret-blink bg-foreground duration-slower" />
90
+ <div className="h-4 w-px animate-caret-blink motion-reduce:animate-none bg-foreground duration-slower" />
48
91
  </div>
49
92
  )}
50
93
  </div>
@@ -15,16 +15,33 @@ import { cn } from "../lib/utils";
15
15
  *
16
16
  * Figma's component bundles a label + helper text; those live in the `Field`
17
17
  * composition (their colour tracks the control's state). This is the bare
18
- * control. Tokens: border `--input`, focus `--ring`, error `--destructive`,
18
+ * control. Tokens: border `--muted-foreground`, focus `--ring`, error `--destructive`,
19
19
  * radius `--radius-sm` (Figma `input` = 4px), padding `--spacing-3`,
20
20
  * text = Body Medium (14 / 20, +0.25).
21
+ *
22
+ * It follows the text-entry state contract (TOKENS.md, "Text entry and navigation"):
23
+ *
24
+ * rest edge `--muted-foreground` at 80% — 3.4:1 light / 5.4:1 dark on a card, where `--input` was
25
+ * 2.2:1 / 2.4:1 and was the only thing marking where the field is (SC 1.4.11)
26
+ * hover a pointer that can hover: the edge steps to full `--muted-foreground`. Not on a focused,
27
+ * invalid, read-only or disabled field — hover never takes over a stronger state
28
+ * focus `--action` edge + the `--shadow-focus` ring, the strongest signal, unchanged by the pointer
29
+ * invalid `aria-invalid="true"` → `--destructive` edge and ring, which hover does not replace; the
30
+ * field's error text is the non-colour cue (SC 1.4.1)
31
+ * read-only an inset `--muted` fill, full-strength text, no hover: readable and selectable, not editable
32
+ * disabled `--opacity-disabled`, inert
33
+ *
34
+ * Motion: border-color, box-shadow and background-color over `duration-instant` with `ease-standard`.
35
+ * Reduced motion collapses it (the library floor) and lands on the same end state.
21
36
  */
22
37
  const inputVariants = cva(
23
38
  [
24
- "flex w-full border border-input bg-background px-3 py-3",
39
+ "flex w-full border border-muted-foreground/80 bg-background px-3 py-3",
25
40
  "font-sans text-body-md text-foreground",
26
41
  "placeholder:text-muted-foreground",
27
- "outline-none transition-colors",
42
+ "outline-none transition-[border-color,box-shadow,background-color] duration-instant ease-standard",
43
+ "[@media(hover:hover)]:enabled:hover:[&:not(:focus):not([aria-invalid=true]):not([readonly]):not([data-state=focus])]:border-muted-foreground",
44
+ "[&[readonly]]:bg-muted",
28
45
  "focus-visible:border-action focus-visible:shadow-focus",
29
46
  "disabled:cursor-not-allowed disabled:opacity-disabled",
30
47
  "aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:border-destructive aria-[invalid=true]:focus-visible:shadow-focus-destructive",
@@ -125,7 +125,7 @@ function JsonNode({
125
125
  aria-label={expanded ? "Collapse" : "Expand"}
126
126
  className="mt-0.5 inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground hover:text-foreground"
127
127
  >
128
- <ChevronRight className={cn("size-3 transition-transform", expanded && "rotate-90")} />
128
+ <ChevronRight className={cn("size-3 transition-transform", expanded ? "rotate-90" : "rtl:-scale-x-100")} />
129
129
  </button>
130
130
  <span>
131
131
  {name !== undefined && <span className="text-muted-foreground">{JSON.stringify(name)}: </span>}
@@ -4,6 +4,7 @@ import * as React from "react";
4
4
  import * as MenubarPrimitive from "@radix-ui/react-menubar";
5
5
  import { Check, ChevronRight, Circle } from "lucide-react";
6
6
  import { cn } from "../lib/utils";
7
+ import { withDirectionalPortal } from "./direction-provider";
7
8
 
8
9
  const MenubarMenu = (props: React.ComponentProps<typeof MenubarPrimitive.Menu>) => (
9
10
  <MenubarPrimitive.Menu {...props} />
@@ -11,9 +12,7 @@ const MenubarMenu = (props: React.ComponentProps<typeof MenubarPrimitive.Menu>)
11
12
  const MenubarGroup = (props: React.ComponentProps<typeof MenubarPrimitive.Group>) => (
12
13
  <MenubarPrimitive.Group {...props} />
13
14
  );
14
- const MenubarPortal = (props: React.ComponentProps<typeof MenubarPrimitive.Portal>) => (
15
- <MenubarPrimitive.Portal {...props} />
16
- );
15
+ const MenubarPortal = withDirectionalPortal(MenubarPrimitive.Portal);
17
16
  const MenubarSub = (props: React.ComponentProps<typeof MenubarPrimitive.Sub>) => (
18
17
  <MenubarPrimitive.Sub {...props} />
19
18
  );
@@ -24,7 +23,14 @@ const MenubarRadioGroup = (props: React.ComponentProps<typeof MenubarPrimitive.R
24
23
  const itemCls =
25
24
  "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground focus:ring-1 focus:ring-inset focus:ring-ring data-[disabled]:pointer-events-none data-[disabled]:opacity-disabled";
26
25
  const contentCls =
27
- "z-overlay min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md font-sans data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95";
26
+ // Capped to the room Radix reports it has, so large text cannot push the surface past the window —
27
+ // see the note in popover.tsx for the measurement that prompted it.
28
+ // `min-w` yields to the available room, which is not a detail: `min-width` beats `max-width` in CSS,
29
+ // so the cap above was unenforceable on its own. Measured on a 390px phone with the root font size
30
+ // doubled — 8rem becomes 256px, the cap resolved to 193px, and the menu came out 256px wide with its
31
+ // right edge 63px past the window and those items unreachable. `min(...)` keeps the comfortable
32
+ // minimum whenever there is room for it and gives it up when there is not.
33
+ "z-overlay max-w-[var(--radix-popper-available-width)] min-w-[min(12rem,var(--radix-popper-available-width))] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md font-sans data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:duration-fast data-[state=open]:ease-enter";
28
34
 
29
35
  const Menubar = React.forwardRef<
30
36
  React.ElementRef<typeof MenubarPrimitive.Root>,
@@ -57,9 +63,9 @@ const MenubarSubTrigger = React.forwardRef<
57
63
  React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
58
64
  React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & { inset?: boolean }
59
65
  >(({ className, inset, children, ...props }, ref) => (
60
- <MenubarPrimitive.SubTrigger ref={ref} className={cn(itemCls, "data-[state=open]:bg-accent", inset && "pl-8", className)} {...props}>
66
+ <MenubarPrimitive.SubTrigger ref={ref} className={cn(itemCls, "data-[state=open]:bg-accent", inset && "ps-8", className)} {...props}>
61
67
  {children}
62
- <ChevronRight className="ml-auto size-4" />
68
+ <ChevronRight className="me-0 ms-auto size-4 rtl:-scale-x-100" />
63
69
  </MenubarPrimitive.SubTrigger>
64
70
  ));
65
71
  MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
@@ -76,7 +82,7 @@ const MenubarContent = React.forwardRef<
76
82
  React.ElementRef<typeof MenubarPrimitive.Content>,
77
83
  React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
78
84
  >(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (
79
- <MenubarPrimitive.Portal>
85
+ <MenubarPortal>
80
86
  <MenubarPrimitive.Content
81
87
  ref={ref}
82
88
  align={align}
@@ -85,7 +91,7 @@ const MenubarContent = React.forwardRef<
85
91
  className={cn(contentCls, className)}
86
92
  {...props}
87
93
  />
88
- </MenubarPrimitive.Portal>
94
+ </MenubarPortal>
89
95
  ));
90
96
  MenubarContent.displayName = MenubarPrimitive.Content.displayName;
91
97
 
@@ -93,7 +99,7 @@ const MenubarItem = React.forwardRef<
93
99
  React.ElementRef<typeof MenubarPrimitive.Item>,
94
100
  React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & { inset?: boolean }
95
101
  >(({ className, inset, ...props }, ref) => (
96
- <MenubarPrimitive.Item ref={ref} className={cn(itemCls, inset && "pl-8", className)} {...props} />
102
+ <MenubarPrimitive.Item ref={ref} className={cn(itemCls, inset && "ps-8", className)} {...props} />
97
103
  ));
98
104
  MenubarItem.displayName = MenubarPrimitive.Item.displayName;
99
105
 
@@ -101,8 +107,8 @@ const MenubarCheckboxItem = React.forwardRef<
101
107
  React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
102
108
  React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
103
109
  >(({ className, children, checked, ...props }, ref) => (
104
- <MenubarPrimitive.CheckboxItem ref={ref} className={cn(itemCls, "pl-8", className)} checked={checked} {...props}>
105
- <span className="absolute left-2 flex size-3.5 items-center justify-center">
110
+ <MenubarPrimitive.CheckboxItem ref={ref} className={cn(itemCls, "ps-8", className)} checked={checked} {...props}>
111
+ <span className="absolute start-2 flex size-3.5 items-center justify-center">
106
112
  <MenubarPrimitive.ItemIndicator>
107
113
  <Check className="size-4" />
108
114
  </MenubarPrimitive.ItemIndicator>
@@ -116,8 +122,8 @@ const MenubarRadioItem = React.forwardRef<
116
122
  React.ElementRef<typeof MenubarPrimitive.RadioItem>,
117
123
  React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
118
124
  >(({ className, children, ...props }, ref) => (
119
- <MenubarPrimitive.RadioItem ref={ref} className={cn(itemCls, "pl-8", className)} {...props}>
120
- <span className="absolute left-2 flex size-3.5 items-center justify-center">
125
+ <MenubarPrimitive.RadioItem ref={ref} className={cn(itemCls, "ps-8", className)} {...props}>
126
+ <span className="absolute start-2 flex size-3.5 items-center justify-center">
121
127
  <MenubarPrimitive.ItemIndicator>
122
128
  <Circle className="size-2 fill-current" />
123
129
  </MenubarPrimitive.ItemIndicator>
@@ -131,7 +137,7 @@ const MenubarLabel = React.forwardRef<
131
137
  React.ElementRef<typeof MenubarPrimitive.Label>,
132
138
  React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & { inset?: boolean }
133
139
  >(({ className, inset, ...props }, ref) => (
134
- <MenubarPrimitive.Label ref={ref} className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)} {...props} />
140
+ <MenubarPrimitive.Label ref={ref} className={cn("px-2 py-1.5 text-sm font-semibold", inset && "ps-8", className)} {...props} />
135
141
  ));
136
142
  MenubarLabel.displayName = MenubarPrimitive.Label.displayName;
137
143
 
@@ -144,7 +150,7 @@ const MenubarSeparator = React.forwardRef<
144
150
  MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
145
151
 
146
152
  const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
147
- <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />
153
+ <span className={cn("ms-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />
148
154
  );
149
155
  MenubarShortcut.displayName = "MenubarShortcut";
150
156
 
@@ -68,9 +68,10 @@ function Modal({
68
68
  <DialogOverlay />
69
69
  <DialogPrimitive.Content
70
70
  className={cn(
71
+ // rtl-ok: centering trick, direction-agnostic by construction — same decision as dialog.tsx
71
72
  "fixed left-1/2 top-1/2 z-overlay flex w-full max-w-[480px] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-xl bg-background font-sans",
72
- "shadow-[0_4px_24px_rgba(0,0,0,0.12)]",
73
- "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
73
+ "shadow-xl",
74
+ "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=open]:duration-fast data-[state=open]:ease-enter data-[state=closed]:duration-instant data-[state=closed]:ease-exit",
74
75
  className,
75
76
  )}
76
77
  >