@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
@@ -0,0 +1,3 @@
1
+ {
2
+ "sourceHash": "78c26aad143f97d1102e3ced1e36fd316846381f865c9f89ee77924f1ffd7e0b"
3
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kinetixui/ui",
3
- "version": "0.23.3",
3
+ "version": "0.24.0",
4
4
  "private": false,
5
5
  "description": "KinetixUI React components — CVA + Radix + Tailwind on the KinetixUI token contract. Also distributed via the kinetixui CLI as a component registry.",
6
6
  "keywords": [
@@ -44,7 +44,7 @@
44
44
  "@dnd-kit/sortable": "^10.0.0",
45
45
  "@dnd-kit/utilities": "^3.2.2",
46
46
  "@hookform/resolvers": "^5.9.1",
47
- "@kinetixui/tokens": "0.23.3",
47
+ "@kinetixui/tokens": "0.24.0",
48
48
  "@radix-ui/react-accordion": "^1.2.20",
49
49
  "@radix-ui/react-alert-dialog": "^1.1.23",
50
50
  "@radix-ui/react-aspect-ratio": "^1.1.15",
@@ -79,10 +79,10 @@
79
79
  "date-fns": "^4.4.0",
80
80
  "embla-carousel-react": "^8.6.0",
81
81
  "input-otp": "^1.5.0",
82
- "lucide-react": "^1.47.0",
82
+ "lucide-react": "^1.48.0",
83
83
  "react-day-picker": "^10.0.1",
84
84
  "react-hook-form": "^7.88.0",
85
- "react-resizable-panels": "^4.12.4",
85
+ "react-resizable-panels": "^4.13.3",
86
86
  "recharts": "^3.10.1",
87
87
  "sonner": "^2.0.8",
88
88
  "tailwind-merge": "^3.7.0",
@@ -111,11 +111,11 @@
111
111
  "tailwindcss": "^3.4.19",
112
112
  "tsup": "^8.5.1",
113
113
  "typescript": "^5.9.3",
114
- "typescript-eslint": "^8.70.0",
115
- "vitest": "^5.0.1"
114
+ "typescript-eslint": "^8.70.1",
115
+ "vitest": "^5.0.2"
116
116
  },
117
117
  "scripts": {
118
- "build": "tsup src/index.ts --format esm --dts --external react --external react-dom",
118
+ "build": "tsup src/index.ts --format esm --dts --external react --external react-dom && node ../../scripts/ui-dist-stamp.mjs --write",
119
119
  "lint": "eslint src",
120
120
  "typecheck": "tsc --noEmit",
121
121
  "test": "vitest run",
@@ -23,7 +23,7 @@ const AccordionTrigger = React.forwardRef<
23
23
  <AccordionPrimitive.Trigger
24
24
  ref={ref}
25
25
  className={cn(
26
- "flex flex-1 items-center justify-between py-4 text-sm font-medium font-sans transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
26
+ "flex flex-1 items-center justify-between py-4 text-sm font-medium font-sans transition-colors duration-fast hover:underline [&[data-state=open]>svg]:rotate-180",
27
27
  className,
28
28
  )}
29
29
  {...props}
@@ -3,11 +3,12 @@
3
3
  import * as React from "react";
4
4
  import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
5
5
  import { cn } from "../lib/utils";
6
+ import { withDirectionalPortal } from "./direction-provider";
6
7
  import { buttonVariants } from "./button";
7
8
 
8
9
  const AlertDialog = AlertDialogPrimitive.Root;
9
10
  const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
10
- const AlertDialogPortal = AlertDialogPrimitive.Portal;
11
+ const AlertDialogPortal = withDirectionalPortal(AlertDialogPrimitive.Portal);
11
12
 
12
13
  const AlertDialogOverlay = React.forwardRef<
13
14
  React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
@@ -16,7 +17,7 @@ const AlertDialogOverlay = React.forwardRef<
16
17
  <AlertDialogPrimitive.Overlay
17
18
  ref={ref}
18
19
  className={cn(
19
- "fixed inset-0 z-overlay bg-foreground/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
20
+ "fixed inset-0 z-overlay bg-scrim backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:duration-fast data-[state=open]:ease-enter data-[state=closed]:duration-instant data-[state=closed]:ease-exit",
20
21
  className,
21
22
  )}
22
23
  {...props}
@@ -33,8 +34,9 @@ const AlertDialogContent = React.forwardRef<
33
34
  <AlertDialogPrimitive.Content
34
35
  ref={ref}
35
36
  className={cn(
37
+ // rtl-ok: centering trick, direction-agnostic by construction — same decision as dialog.tsx
36
38
  "fixed left-1/2 top-1/2 z-overlay grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg sm:rounded-xl font-sans",
37
- "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",
39
+ "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",
38
40
  className,
39
41
  )}
40
42
  {...props}
@@ -44,13 +46,13 @@ const AlertDialogContent = React.forwardRef<
44
46
  AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
45
47
 
46
48
  const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
47
- <div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
49
+ <div className={cn("flex flex-col space-y-2 text-center sm:text-start", className)} {...props} />
48
50
  );
49
51
  AlertDialogHeader.displayName = "AlertDialogHeader";
50
52
 
51
53
  const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
52
54
  <div
53
- className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
55
+ className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
54
56
  {...props}
55
57
  />
56
58
  );
@@ -53,8 +53,11 @@ const AvatarGroup = React.forwardRef<HTMLDivElement, AvatarGroupProps>(
53
53
  const visible = max ? items.slice(0, max) : items;
54
54
  const overflow = max ? items.length - max : 0;
55
55
 
56
+ // rtl-ok: negative spacing IS the stack overlap; `gap` cannot be negative, and it mirrors naturally
57
+ const stack = "flex -space-x-2";
58
+
56
59
  return (
57
- <div ref={ref} className={cn("flex -space-x-2", className)} {...props}>
60
+ <div ref={ref} className={cn(stack, className)} {...props}>
58
61
  {visible.map((child, i) => (
59
62
  <div key={i} className="rounded-full ring-2 ring-background">
60
63
  {child}
@@ -48,12 +48,18 @@ export interface BannerProps
48
48
  VariantProps<typeof bannerVariants> {
49
49
  hideIcon?: boolean;
50
50
  onDismiss?: () => void;
51
+ /**
52
+ * Replaces the default dismiss mark (lucide's `X`) with your own icon — any icon library or SVG.
53
+ * The button keeps its accessible name ("Dismiss") and sizes any SVG inside it to 14px, so the icon
54
+ * needs no size, colour or aria attributes of its own. See /docs/icons.
55
+ */
56
+ dismissIcon?: React.ReactNode;
51
57
  /** optional CTA rendered inline after the message */
52
58
  action?: { label: React.ReactNode; onClick?: () => void };
53
59
  }
54
60
 
55
61
  const Banner = React.forwardRef<HTMLDivElement, BannerProps>(
56
- ({ className, variant = "information", sticky, hideIcon, onDismiss, action, children, ...props }, ref) => {
62
+ ({ className, variant = "information", sticky, hideIcon, onDismiss, dismissIcon, action, children, ...props }, ref) => {
57
63
  const Icon = ICON[variant ?? "information"];
58
64
  return (
59
65
  <div ref={ref} className={cn(bannerVariants({ variant, sticky }), className)} {...props}>
@@ -75,9 +81,9 @@ const Banner = React.forwardRef<HTMLDivElement, BannerProps>(
75
81
  type="button"
76
82
  onClick={onDismiss}
77
83
  aria-label="Dismiss"
78
- 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"
84
+ 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"
79
85
  >
80
- <X className="size-3.5" />
86
+ {dismissIcon ?? <X className="size-3.5" />}
81
87
  </button>
82
88
  )}
83
89
  </div>
@@ -53,7 +53,7 @@ BreadcrumbPage.displayName = "BreadcrumbPage";
53
53
 
54
54
  const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
55
55
  <li role="presentation" aria-hidden className={cn("[&>svg]:size-3.5", className)} {...props}>
56
- {children ?? <ChevronRight />}
56
+ {children ?? <ChevronRight className="rtl:-scale-x-100" />}
57
57
  </li>
58
58
  );
59
59
  BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
@@ -26,7 +26,7 @@ const buttonVariants = cva(
26
26
  [
27
27
  "inline-flex items-center justify-center gap-1 shrink-0",
28
28
  "font-sans font-medium whitespace-nowrap select-none",
29
- "rounded-md transition-colors outline-none",
29
+ "rounded-md transition-colors duration-instant ease-standard outline-none",
30
30
  "focus-visible:shadow-focus",
31
31
  "disabled:pointer-events-none disabled:opacity-disabled",
32
32
  "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-[18px]",
@@ -1,17 +1,88 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
+ import { Slot } from "@radix-ui/react-slot";
4
5
  import { cn } from "../lib/utils";
5
6
 
6
- const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
7
- ({ className, ...props }, ref) => (
8
- <div
7
+ /**
8
+ * Card — a raised surface (TOKENS.md, "Surface model").
9
+ *
10
+ * At rest it is separated from the page by elevation and a soft edge, not by a heavy stroke: the edge is
11
+ * `--border` at half strength, so the card no longer draws the same line as the inputs and buttons inside
12
+ * it, and the `md` step of the elevation ladder gives it a contact shadow that the old `sm` (5% black)
13
+ * did not visibly have. Both stay on tokens, so Create's surface treatments still reach it — `flat` and
14
+ * `bordered` remove the shadow, `elevated` raises it a rung. On a grouped section (`bg-surface-grouped`)
15
+ * the surface step does most of the work, in dark mode too.
16
+ *
17
+ * A Card is static. It has no hover, no pressed state and no pointer cursor, because a surface that
18
+ * reacts to the pointer reads as clickable, and a static card that does is a false affordance.
19
+ *
20
+ * Interaction is opt-in through semantics, not through a flag: `asChild` renders the card as its single
21
+ * child, and the interactive states below are keyed on that element BEING interactive — an `<a href>` or
22
+ * a `<button>`. So the visual states cannot drift from what the element actually does: a `<div>` never
23
+ * gets them, a link always does, and a consumer cannot style a static card as clickable by accident.
24
+ * One action per card: the element is the action, so it must not contain other controls.
25
+ *
26
+ * hover a pointer that can hover (`@media (hover: hover)`) — edge to full `--border`, lifted to `lg`
27
+ * pressed `:active` — back down to `sm` with a `muted` wash, so it differs from hover in depth AND fill
28
+ * focus the shared `shadow-focus` ring, which replaces the elevation while focused and so stays the
29
+ * strongest edge on the card
30
+ * selected `aria-pressed="true"` (a toggle button) or `aria-current` (a link to where you are) — a
31
+ * 2px `--primary` edge: a change of WEIGHT as well as colour, so it is not carried by hue alone
32
+ *
33
+ * Motion: box-shadow, border-color and background-color over `duration-fast` with `ease-standard`, the
34
+ * catalogue's tier for a state change on a control. Nothing moves or resizes, so there is no layout shift,
35
+ * and under `prefers-reduced-motion` the transition is removed and every state lands immediately.
36
+ */
37
+ // Every class is written out in full: Tailwind finds classes by scanning source text, so a selector
38
+ // assembled from constants would never be generated.
39
+ const INTERACTIVE = [
40
+ // A link is inline by default and a button centres its text, so the element is made a block that reads
41
+ // from the start edge. These come after plain utilities in the stylesheet, so lay an interactive card out
42
+ // with flex or grid on an inner element (as CardHeader does) rather than on the card itself.
43
+ "[&:where(a[href],button)]:block [&:where(button)]:text-start [&:is(a[href],button)]:cursor-pointer",
44
+ "[&:is(a[href],button)]:outline-none",
45
+ "[&:is(a[href],button)]:transition-[box-shadow,border-color,background-color] [&:is(a[href],button)]:duration-fast [&:is(a[href],button)]:ease-standard motion-reduce:transition-none",
46
+ "[@media(hover:hover)]:[&:is(a[href],button)]:hover:border-border [@media(hover:hover)]:[&:is(a[href],button)]:hover:shadow-lg",
47
+ "[&:is(a[href],button)]:active:shadow-sm [&:is(a[href],button)]:active:bg-muted/40",
48
+ // Selected: an inset 1px of --primary inside the 1px --primary border makes a 2px edge, with the
49
+ // elevation of whichever state it is in alongside, since the ring and the elevation share box-shadow.
50
+ // Restated for hover and pressed, which would otherwise take the border and shadow back. Written as the
51
+ // property, not `shadow-[…]`: Tailwind reads an arbitrary shadow that contains `hsl(` as a shadow COLOUR
52
+ // and the ring silently never rendered — `check:card-visual` caught it.
53
+ "[&:is([aria-pressed=true],[aria-current]:not([aria-current=false]))]:border-primary",
54
+ "[&:is([aria-pressed=true],[aria-current]:not([aria-current=false]))]:[box-shadow:inset_0_0_0_1px_hsl(var(--primary)),var(--shadow-md)]",
55
+ "[@media(hover:hover)]:[&:is([aria-pressed=true],[aria-current]:not([aria-current=false]))]:hover:border-primary",
56
+ "[@media(hover:hover)]:[&:is([aria-pressed=true],[aria-current]:not([aria-current=false]))]:hover:[box-shadow:inset_0_0_0_1px_hsl(var(--primary)),var(--shadow-lg)]",
57
+ "[&:is([aria-pressed=true],[aria-current]:not([aria-current=false]))]:active:[box-shadow:inset_0_0_0_1px_hsl(var(--primary)),var(--shadow-sm)]",
58
+ // Focus replaces the elevation with the shared ring and is the strongest edge the card can show. It is
59
+ // `!important` because hover and selected also set box-shadow with selectors at least as specific, and
60
+ // whichever of those Tailwind happens to emit last must not be able to hide the focus ring.
61
+ "[&:is(a[href],button)]:focus-visible:!shadow-focus",
62
+ "disabled:pointer-events-none disabled:opacity-disabled",
63
+ ].join(" ");
64
+
65
+ export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
66
+ /** Render as the single child element (Radix Slot) instead of a `<div>` — e.g.
67
+ * `<Card asChild><a href="/reports/q3">…</a></Card>`. When that element is an `<a href>` or a `<button>`,
68
+ * the card takes the interactive states; anything else stays static. */
69
+ asChild?: boolean;
70
+ }
71
+
72
+ const Card = React.forwardRef<HTMLDivElement, CardProps>(({ className, asChild = false, ...props }, ref) => {
73
+ const Comp = asChild ? Slot : "div";
74
+ return (
75
+ <Comp
9
76
  ref={ref}
10
- className={cn("rounded-xl border bg-card text-card-foreground shadow-sm font-sans", className)}
77
+ className={cn(
78
+ "rounded-xl border border-border/50 bg-card text-card-foreground shadow-md font-sans",
79
+ INTERACTIVE,
80
+ className,
81
+ )}
11
82
  {...props}
12
83
  />
13
- ),
14
- );
84
+ );
85
+ });
15
86
  Card.displayName = "Card";
16
87
 
17
88
  const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
@@ -6,10 +6,26 @@ import { Check, Minus } from "lucide-react";
6
6
  import { cn } from "../lib/utils";
7
7
 
8
8
  /**
9
- * Checkbox — reconciled 1:1 with the KinetixUI design source, node 54863:483.
10
- * 18px box, 4px radius, 2px border. Unchecked border = `--input`; checked /
11
- * indeterminate fill = `--primary`. Focus = 2px `--ring` offset. `aria-invalid`
12
- * paints the box (and the composed label/helper) with `--destructive`.
9
+ * Checkbox — from the KinetixUI design source, node 54863:483: 18px box, 4px radius, 2px border, checked /
10
+ * indeterminate fill = `--action`, focus = 2px `--ring` offset.
11
+ *
12
+ * It follows the selection-control state contract (TOKENS.md, "Selection controls"):
13
+ *
14
+ * rest unchecked boundary `--muted-foreground`, not `--input`. The box is the only thing that says a
15
+ * checkbox is there, so its edge must clear SC 1.4.11's 3:1 (`--input` is 2.2:1 light / 2.7:1
16
+ * dark — a tracked exception for field outlines, where the field's fill and label carry it).
17
+ * hover a pointer that can hover: a `--foreground` state layer at 8% around the box, and the unchecked
18
+ * edge darkens to `--foreground`; checked fills step to `--action`/90
19
+ * pressed the state layer deepens to 14%; checked fills to `--action`/85
20
+ * checked `--action` fill AND a glyph — shape, not only colour
21
+ * invalid `aria-invalid="true"` → `--destructive` edge (and fill when checked); the field's error text
22
+ * is the non-colour cue
23
+ * focus the 2px offset ring, `!important` so no hover or pressed layer can cover it
24
+ * disabled `--opacity-disabled`, and the hover/pressed layers are keyed on `:enabled`, so it never answers
25
+ *
26
+ * Motion: colour and box-shadow over `duration-instant` (100ms, the press/toggle step) with `ease-standard`.
27
+ * The checked state itself does not animate. Reduced motion collapses the transition (the library floor in
28
+ * tailwind.config.ts) and lands on the same end state.
13
29
  */
14
30
  const Checkbox = React.forwardRef<
15
31
  React.ElementRef<typeof CheckboxPrimitive.Root>,
@@ -18,12 +34,30 @@ const Checkbox = React.forwardRef<
18
34
  <CheckboxPrimitive.Root
19
35
  ref={ref}
20
36
  className={cn(
21
- "peer size-[18px] shrink-0 rounded-sm border-2 border-input outline-none transition-colors",
22
- "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
23
- "disabled:cursor-not-allowed disabled:opacity-disabled",
37
+ // 1.125rem, not the 18px it replaces: identical at the default root size, but a reader who raises
38
+ // their browser's default font size scales rem and not px. At 200% the box stayed 18x18 beside a
39
+ // label that doubled — the control shrank by half relative to its own text, and its hit target with it.
40
+ "peer size-[1.125rem] shrink-0 rounded-sm border-2 border-muted-foreground outline-none",
41
+ "transition-[color,background-color,border-color,box-shadow] duration-instant ease-standard",
24
42
  "data-[state=checked]:border-action data-[state=checked]:bg-action data-[state=checked]:text-action-foreground",
25
43
  "data-[state=indeterminate]:border-action data-[state=indeterminate]:bg-action data-[state=indeterminate]:text-action-foreground",
26
- "aria-invalid:border-destructive aria-invalid:data-[state=checked]:border-destructive aria-invalid:data-[state=checked]:bg-destructive",
44
+ // `aria-[invalid=true]:`, not `aria-invalid:` — Tailwind 3 has no `invalid` in its aria variants, so
45
+ // the short form compiled to nothing and an invalid checkbox looked exactly like a valid one.
46
+ "aria-[invalid=true]:border-destructive aria-[invalid=true]:data-[state=checked]:bg-destructive aria-[invalid=true]:data-[state=indeterminate]:bg-destructive",
47
+ // The state layer: the same for every value, so hover and press read the same way checked or not.
48
+ "[@media(hover:hover)]:enabled:hover:[box-shadow:0_0_0_5px_hsl(var(--foreground)/0.08)]",
49
+ // Pressed is written twice: bare for touch, and again inside the hover media query, because Tailwind
50
+ // emits media-wrapped utilities after plain ones and the hover layer would otherwise outrank the press.
51
+ "enabled:active:[box-shadow:0_0_0_5px_hsl(var(--foreground)/0.14)]",
52
+ "[@media(hover:hover)]:enabled:active:[box-shadow:0_0_0_5px_hsl(var(--foreground)/0.14)]",
53
+ // ...and the box itself answers in the colour it already has. Valid only: an invalid box keeps its
54
+ // `--destructive` edge under the pointer, or hovering would erase the one cue the box carries.
55
+ "[@media(hover:hover)]:enabled:hover:[&[data-state=unchecked]:not([aria-invalid=true])]:border-foreground",
56
+ "[@media(hover:hover)]:enabled:hover:[&:is([data-state=checked],[data-state=indeterminate]):not([aria-invalid=true])]:bg-action/90",
57
+ "enabled:active:[&:is([data-state=checked],[data-state=indeterminate]):not([aria-invalid=true])]:bg-action/85",
58
+ "[@media(hover:hover)]:enabled:active:[&:is([data-state=checked],[data-state=indeterminate]):not([aria-invalid=true])]:bg-action/85",
59
+ "focus-visible:!ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
60
+ "disabled:cursor-not-allowed disabled:opacity-disabled",
27
61
  className,
28
62
  )}
29
63
  {...props}
@@ -1,9 +1,49 @@
1
1
  "use client";
2
2
 
3
+ import * as React from "react";
3
4
  import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
5
+ import { cn } from "../lib/utils";
4
6
 
5
7
  const Collapsible = CollapsiblePrimitive.Root;
6
8
  const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
7
- const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
9
+
10
+ /**
11
+ * The disclosure transition, which this component shipped without.
12
+ *
13
+ * Collapsible was a bare re-export: three primitives passed through untouched. Its content appeared
14
+ * and vanished in a single frame while `Accordion` — the same disclosure gesture on a sibling Radix
15
+ * primitive — animated its height. Measured before this change, the content went 0px to 84px with no
16
+ * animation at all, against the accordion's 0px → 24.64px → 36px over 200ms.
17
+ *
18
+ * Disclosure is the case where motion carries meaning rather than decorating it: the content growing
19
+ * out of the trigger is what says it belongs to the thing you just pressed, and where it will go when
20
+ * you press it again. Appearing instantly makes the page look like it was replaced instead.
21
+ *
22
+ * `overflow-hidden` is load-bearing, not cosmetic — the height animation clips its content as it
23
+ * runs, and without it the text is laid out at full height from the first frame and simply spills
24
+ * out of the collapsing box.
25
+ *
26
+ * Reduced motion needs nothing here. The package's base layer collapses `animation-duration` for
27
+ * everything it ships, and the content still lands at its full height because the keyframe's end
28
+ * state is unchanged — proven in both directions by `scripts/motion.mjs`.
29
+ *
30
+ * Wrapping the primitive rather than re-exporting it adds a `className` that merges with the
31
+ * caller's, which is the convention every other component here follows. The props, the ref and the
32
+ * data attributes are the primitive's own, so nothing a consumer passes today behaves differently.
33
+ */
34
+ const CollapsibleContent = React.forwardRef<
35
+ React.ElementRef<typeof CollapsiblePrimitive.CollapsibleContent>,
36
+ React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleContent>
37
+ >(({ className, ...props }, ref) => (
38
+ <CollapsiblePrimitive.CollapsibleContent
39
+ ref={ref}
40
+ className={cn(
41
+ "overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down",
42
+ className,
43
+ )}
44
+ {...props}
45
+ />
46
+ ));
47
+ CollapsibleContent.displayName = CollapsiblePrimitive.CollapsibleContent.displayName;
8
48
 
9
49
  export { Collapsible, CollapsibleTrigger, CollapsibleContent };
@@ -38,7 +38,7 @@ const CommandInput = React.forwardRef<
38
38
  <div className="flex items-center border-b px-3" cmdk-input-wrapper="">
39
39
  {/* Resting-dim icon, no hover/disabled state — numerically the disabled
40
40
  value (0.5) but not that role; left as a literal, see calendar.tsx */}
41
- <Search className="mr-2 size-4 shrink-0 opacity-50" />
41
+ <Search className="me-2 size-4 shrink-0 opacity-50" />
42
42
  <CommandPrimitive.Input
43
43
  ref={ref}
44
44
  className={cn(
@@ -108,7 +108,7 @@ const CommandItem = React.forwardRef<
108
108
  CommandItem.displayName = CommandPrimitive.Item.displayName;
109
109
 
110
110
  const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
111
- <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />
111
+ <span className={cn("ms-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />
112
112
  );
113
113
  CommandShortcut.displayName = "CommandShortcut";
114
114
 
@@ -4,18 +4,26 @@ import * as React from "react";
4
4
  import * as ContextMenuPrimitive from "@radix-ui/react-context-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 ContextMenu = ContextMenuPrimitive.Root;
9
10
  const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
10
11
  const ContextMenuGroup = ContextMenuPrimitive.Group;
11
- const ContextMenuPortal = ContextMenuPrimitive.Portal;
12
+ const ContextMenuPortal = withDirectionalPortal(ContextMenuPrimitive.Portal);
12
13
  const ContextMenuSub = ContextMenuPrimitive.Sub;
13
14
  const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
14
15
 
15
16
  const itemCls =
16
17
  "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";
17
18
  const contentCls =
18
- "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";
19
+ // Capped to the room Radix reports it has, so large text cannot push the surface past the window —
20
+ // see the note in popover.tsx for the measurement that prompted it.
21
+ // `min-w` yields to the available room, which is not a detail: `min-width` beats `max-width` in CSS,
22
+ // so the cap above was unenforceable on its own. Measured on a 390px phone with the root font size
23
+ // doubled — 8rem becomes 256px, the cap resolved to 193px, and the menu came out 256px wide with its
24
+ // right edge 63px past the window and those items unreachable. `min(...)` keeps the comfortable
25
+ // minimum whenever there is room for it and gives it up when there is not.
26
+ "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";
19
27
 
20
28
  const ContextMenuSubTrigger = React.forwardRef<
21
29
  React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
@@ -23,11 +31,11 @@ const ContextMenuSubTrigger = React.forwardRef<
23
31
  >(({ className, inset, children, ...props }, ref) => (
24
32
  <ContextMenuPrimitive.SubTrigger
25
33
  ref={ref}
26
- className={cn(itemCls, "data-[state=open]:bg-accent", inset && "pl-8", className)}
34
+ className={cn(itemCls, "data-[state=open]:bg-accent", inset && "ps-8", className)}
27
35
  {...props}
28
36
  >
29
37
  {children}
30
- <ChevronRight className="ml-auto size-4" />
38
+ <ChevronRight className="me-0 ms-auto size-4 rtl:-scale-x-100" />
31
39
  </ContextMenuPrimitive.SubTrigger>
32
40
  ));
33
41
  ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
@@ -44,9 +52,9 @@ const ContextMenuContent = React.forwardRef<
44
52
  React.ElementRef<typeof ContextMenuPrimitive.Content>,
45
53
  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
46
54
  >(({ className, ...props }, ref) => (
47
- <ContextMenuPrimitive.Portal>
55
+ <ContextMenuPortal>
48
56
  <ContextMenuPrimitive.Content ref={ref} className={cn(contentCls, className)} {...props} />
49
- </ContextMenuPrimitive.Portal>
57
+ </ContextMenuPortal>
50
58
  ));
51
59
  ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
52
60
 
@@ -54,7 +62,7 @@ const ContextMenuItem = React.forwardRef<
54
62
  React.ElementRef<typeof ContextMenuPrimitive.Item>,
55
63
  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & { inset?: boolean }
56
64
  >(({ className, inset, ...props }, ref) => (
57
- <ContextMenuPrimitive.Item ref={ref} className={cn(itemCls, inset && "pl-8", className)} {...props} />
65
+ <ContextMenuPrimitive.Item ref={ref} className={cn(itemCls, inset && "ps-8", className)} {...props} />
58
66
  ));
59
67
  ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
60
68
 
@@ -62,8 +70,8 @@ const ContextMenuCheckboxItem = React.forwardRef<
62
70
  React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
63
71
  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
64
72
  >(({ className, children, checked, ...props }, ref) => (
65
- <ContextMenuPrimitive.CheckboxItem ref={ref} className={cn(itemCls, "pl-8", className)} checked={checked} {...props}>
66
- <span className="absolute left-2 flex size-3.5 items-center justify-center">
73
+ <ContextMenuPrimitive.CheckboxItem ref={ref} className={cn(itemCls, "ps-8", className)} checked={checked} {...props}>
74
+ <span className="absolute start-2 flex size-3.5 items-center justify-center">
67
75
  <ContextMenuPrimitive.ItemIndicator>
68
76
  <Check className="size-4" />
69
77
  </ContextMenuPrimitive.ItemIndicator>
@@ -77,8 +85,8 @@ const ContextMenuRadioItem = React.forwardRef<
77
85
  React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
78
86
  React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
79
87
  >(({ className, children, ...props }, ref) => (
80
- <ContextMenuPrimitive.RadioItem ref={ref} className={cn(itemCls, "pl-8", className)} {...props}>
81
- <span className="absolute left-2 flex size-3.5 items-center justify-center">
88
+ <ContextMenuPrimitive.RadioItem ref={ref} className={cn(itemCls, "ps-8", className)} {...props}>
89
+ <span className="absolute start-2 flex size-3.5 items-center justify-center">
82
90
  <ContextMenuPrimitive.ItemIndicator>
83
91
  <Circle className="size-2 fill-current" />
84
92
  </ContextMenuPrimitive.ItemIndicator>
@@ -94,7 +102,7 @@ const ContextMenuLabel = React.forwardRef<
94
102
  >(({ className, inset, ...props }, ref) => (
95
103
  <ContextMenuPrimitive.Label
96
104
  ref={ref}
97
- className={cn("px-2 py-1.5 text-sm font-semibold text-foreground", inset && "pl-8", className)}
105
+ className={cn("px-2 py-1.5 text-sm font-semibold text-foreground", inset && "ps-8", className)}
98
106
  {...props}
99
107
  />
100
108
  ));
@@ -109,7 +117,7 @@ const ContextMenuSeparator = React.forwardRef<
109
117
  ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
110
118
 
111
119
  const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
112
- <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />
120
+ <span className={cn("ms-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />
113
121
  );
114
122
  ContextMenuShortcut.displayName = "ContextMenuShortcut";
115
123
 
@@ -4,10 +4,11 @@ import * as React from "react";
4
4
  import * as DialogPrimitive from "@radix-ui/react-dialog";
5
5
  import { X } from "lucide-react";
6
6
  import { cn } from "../lib/utils";
7
+ import { withDirectionalPortal } from "./direction-provider";
7
8
 
8
9
  const Dialog = DialogPrimitive.Root;
9
10
  const DialogTrigger = DialogPrimitive.Trigger;
10
- const DialogPortal = DialogPrimitive.Portal;
11
+ const DialogPortal = withDirectionalPortal(DialogPrimitive.Portal);
11
12
  const DialogClose = DialogPrimitive.Close;
12
13
 
13
14
  const DialogOverlay = React.forwardRef<
@@ -17,7 +18,7 @@ const DialogOverlay = React.forwardRef<
17
18
  <DialogPrimitive.Overlay
18
19
  ref={ref}
19
20
  className={cn(
20
- "fixed inset-0 z-overlay bg-foreground/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
21
+ "fixed inset-0 z-overlay bg-scrim backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=open]:duration-fast data-[state=open]:ease-enter data-[state=closed]:duration-instant data-[state=closed]:ease-exit",
21
22
  className,
22
23
  )}
23
24
  {...props}
@@ -36,7 +37,7 @@ const DialogContent = React.forwardRef<
36
37
  className={cn(
37
38
  // rtl-ok: centering trick (left-1/2 + -translate-x-1/2), direction-agnostic by construction
38
39
  "fixed left-1/2 top-1/2 z-overlay grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg sm:rounded-xl font-sans",
39
- "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",
40
+ "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",
40
41
  className,
41
42
  )}
42
43
  {...props}
@@ -58,7 +59,7 @@ DialogHeader.displayName = "DialogHeader";
58
59
 
59
60
  const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
60
61
  <div
61
- className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
62
+ className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
62
63
  {...props}
63
64
  />
64
65
  );