@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
@@ -75,20 +75,29 @@ const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
75
75
  tabIndex={0}
76
76
  onClick={() => setOpen(true)}
77
77
  onKeyDown={(e) => {
78
- if (e.key === "Enter" || e.key === " ") {
78
+ // Enter and Space open it, and so do the vertical arrows: WAI-ARIA's combobox pattern lists
79
+ // Down Arrow as a way to open the popup, and a keyboard user who has just tabbed onto a
80
+ // collapsed combobox reaches for it first. Without it the arrows did nothing at all here —
81
+ // the only way in was Enter or Space.
82
+ if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown" || e.key === "ArrowUp") {
79
83
  e.preventDefault();
80
84
  setOpen(true);
81
85
  }
82
86
  }}
83
87
  className={cn(
84
- "flex min-h-11 w-full flex-wrap items-center gap-1.5 border border-input bg-background px-3 py-2 font-sans",
85
- "outline-none focus-visible:border-action focus-visible:shadow-focus",
88
+ // The text-entry state contract (TOKENS.md, "Composite fields"): one field, whose chips sit inside
89
+ // its edge. `rounded-sm` is the field radius Input and Select already use; this was square.
90
+ "flex min-h-11 w-full flex-wrap items-center gap-1.5 rounded-sm border border-muted-foreground/80 bg-background px-3 py-2 font-sans",
91
+ "outline-none transition-[border-color,box-shadow] duration-instant ease-standard",
92
+ "[@media(hover:hover)]:hover:[&:not(:focus):not([aria-expanded=true]):not([aria-invalid=true])]:border-muted-foreground",
93
+ "focus-visible:border-action focus-visible:shadow-focus",
86
94
  "aria-expanded:border-action aria-expanded:shadow-focus",
95
+ "aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:shadow-focus-destructive aria-[invalid=true]:aria-expanded:shadow-focus-destructive",
87
96
  className,
88
97
  )}
89
98
  {...props}
90
99
  >
91
- {current.length === 0 && <span className="text-body-sm text-muted-foreground">{placeholder}</span>}
100
+ {current.length === 0 && <span className="text-body-md text-muted-foreground">{placeholder}</span>}
92
101
  {current.map((v) => (
93
102
  <Tag key={v} variant="secondary" onRemove={() => remove(v)} onClick={(e) => e.stopPropagation()}>
94
103
  {labelFor(v)}
@@ -125,7 +134,7 @@ const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
125
134
  {canCreate ? (
126
135
  <button
127
136
  type="button"
128
- className="flex w-full items-center px-2 py-1.5 text-left text-body-sm text-foreground hover:bg-accent"
137
+ className="flex w-full items-center px-2 py-1.5 text-start text-body-sm text-foreground hover:bg-accent"
129
138
  onClick={() => {
130
139
  toggle(trimmed);
131
140
  setQuery("");
@@ -12,12 +12,20 @@ import { cn } from "../lib/utils";
12
12
  * Matches Input's `inputVariants` styling 1:1 so the two are drop-in
13
13
  * interchangeable in a Field. Gap-fill addition (not in the original
14
14
  * Figma source) — matches the shadcn/ui Native Select API shape.
15
+ *
16
+ * Same text-entry state contract as Input (TOKENS.md, "Text entry and navigation") — rest edge, hover,
17
+ * focus, invalid, disabled — minus read-only, which `<select>` does not have. Its placeholder is the
18
+ * empty-valued option: while it is the selection the field's text is `--muted-foreground`, so an unanswered
19
+ * select reads as unanswered next to a filled one (it used to render in the value's colour). The options in
20
+ * the open picker keep `--foreground`.
15
21
  */
16
22
  const nativeSelectVariants = cva(
17
23
  [
18
- "flex w-full appearance-none border border-input bg-background px-3 py-3 pe-9",
24
+ "flex w-full appearance-none border border-muted-foreground/80 bg-background px-3 py-3 pe-9",
19
25
  "font-sans text-body-md text-foreground",
20
- "outline-none transition-colors",
26
+ "[&:has(option[value='']:checked)]:text-muted-foreground [&_option]:text-foreground",
27
+ "outline-none transition-[border-color,box-shadow,background-color] duration-instant ease-standard",
28
+ "[@media(hover:hover)]:enabled:hover:[&:not(:focus):not([aria-invalid=true]):not([readonly]):not([data-state=focus])]:border-muted-foreground",
21
29
  "focus-visible:border-action focus-visible:shadow-focus",
22
30
  "disabled:cursor-not-allowed disabled:opacity-disabled",
23
31
  "aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:border-destructive aria-[invalid=true]:focus-visible:shadow-focus-destructive",
@@ -32,7 +32,7 @@ const NavigationBar = React.forwardRef<HTMLElement, NavigationBarProps>(
32
32
  aria-label="Back"
33
33
  className="flex size-9 items-center justify-center rounded-full text-foreground 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"
34
34
  >
35
- <ChevronLeft className="size-5" />
35
+ <ChevronLeft className="size-5 rtl:-scale-x-100" />
36
36
  </button>
37
37
  ))}
38
38
  </div>
@@ -36,7 +36,7 @@ NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName;
36
36
  const NavigationMenuItem = NavigationMenuPrimitive.Item;
37
37
 
38
38
  const navigationMenuTriggerStyle = cva(
39
- "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-disabled data-[state=open]:bg-accent/50",
39
+ "group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-disabled data-[state=open]:bg-accent/50",
40
40
  );
41
41
 
42
42
  const NavigationMenuTrigger = React.forwardRef<
@@ -46,7 +46,7 @@ const NavigationMenuTrigger = React.forwardRef<
46
46
  <NavigationMenuPrimitive.Trigger ref={ref} className={cn(navigationMenuTriggerStyle(), "group", className)} {...props}>
47
47
  {children}
48
48
  <ChevronDown
49
- className="relative top-px ml-1 size-3 transition-transform duration-fast group-data-[state=open]:rotate-180"
49
+ className="relative top-px ms-1 size-3 transition-transform duration-fast group-data-[state=open]:rotate-180"
50
50
  aria-hidden
51
51
  />
52
52
  </NavigationMenuPrimitive.Trigger>
@@ -60,7 +60,7 @@ const NavigationMenuContent = React.forwardRef<
60
60
  <NavigationMenuPrimitive.Content
61
61
  ref={ref}
62
62
  className={cn(
63
- "left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out md:absolute md:w-auto",
63
+ "start-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion^=from-]:duration-fast data-[motion^=from-]:ease-enter data-[motion^=to-]:duration-instant data-[motion^=to-]:ease-exit md:absolute md:w-auto",
64
64
  className,
65
65
  )}
66
66
  {...props}
@@ -74,11 +74,11 @@ const NavigationMenuViewport = React.forwardRef<
74
74
  React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
75
75
  React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
76
76
  >(({ className, ...props }, ref) => (
77
- <div className="absolute left-0 top-full flex justify-center">
77
+ <div className="absolute start-0 top-full flex justify-center">
78
78
  <NavigationMenuPrimitive.Viewport
79
79
  ref={ref}
80
80
  className={cn(
81
- "origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
81
+ "origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 data-[state=open]:duration-fast data-[state=open]:ease-enter data-[state=closed]:duration-instant data-[state=closed]:ease-exit md:w-[var(--radix-navigation-menu-viewport-width)]",
82
82
  className,
83
83
  )}
84
84
  {...props}
@@ -94,7 +94,7 @@ const NavigationMenuIndicator = React.forwardRef<
94
94
  <NavigationMenuPrimitive.Indicator
95
95
  ref={ref}
96
96
  className={cn(
97
- "top-full z-raised flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
97
+ "top-full z-raised flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in data-[state=visible]:duration-fast data-[state=visible]:ease-enter data-[state=hidden]:duration-instant data-[state=hidden]:ease-exit",
98
98
  className,
99
99
  )}
100
100
  {...props}
@@ -6,9 +6,19 @@ import { cn } from "../lib/utils";
6
6
 
7
7
  /**
8
8
  * NumberInput — a numeric field with increment / decrement controls.
9
- * The wrapper shows `--shadow-focus` on `focus-within`; each stepper also
10
- * draws its own `--ring` inset outline on `:focus-visible` so keyboard users
11
- * can tell which control is focused.
9
+ *
10
+ * The wrapper is ONE field and follows the text-entry state contract (TOKENS.md, "Composite fields"); the
11
+ * steppers sit inside its edge, separated by quiet internal dividers rather than borders of their own.
12
+ *
13
+ * rest edge `--muted-foreground` at 80% (3:1 on a card; `--input` was 2.2:1)
14
+ * hover the edge steps to full `--muted-foreground` — never over focus, invalid, read-only or disabled
15
+ * focus the INPUT focused → `--action` edge + `--shadow-focus` on the wrapper. A stepper focused → its
16
+ * own inset `--ring`, and the wrapper stays at rest, so the ring says which part has focus
17
+ * invalid `aria-invalid="true"` (passed through to the input) → `--destructive` edge, kept under the
18
+ * pointer and focus
19
+ * read-only `readOnly` → the inset `--muted` fill, and the steppers are disabled: a read-only value is
20
+ * not one a button may change either
21
+ * disabled `--opacity-disabled`, inert
12
22
  */
13
23
  export interface NumberInputProps
14
24
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "defaultValue" | "onChange"> {
@@ -21,7 +31,7 @@ export interface NumberInputProps
21
31
  }
22
32
 
23
33
  const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
24
- ({ className, value, defaultValue = 0, min, max, step = 1, onChange, disabled, ...props }, ref) => {
34
+ ({ className, value, defaultValue = 0, min, max, step = 1, onChange, disabled, readOnly, ...props }, ref) => {
25
35
  const [internal, setInternal] = React.useState(defaultValue);
26
36
  const current = value ?? internal;
27
37
 
@@ -41,7 +51,14 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
41
51
  return (
42
52
  <div
43
53
  className={cn(
44
- "flex h-10 items-stretch overflow-hidden rounded-md border border-input font-sans focus-within:shadow-focus",
54
+ // no fixed height: the input's own padding sets it, so it matches Input and InputGroup in the same row
55
+ // (it was a fixed 40px beside their 46px) and still grows with the text
56
+ "flex items-stretch overflow-hidden rounded-md border border-muted-foreground/80 bg-background font-sans",
57
+ "transition-[border-color,box-shadow,background-color] duration-instant ease-standard",
58
+ !disabled && "[@media(hover:hover)]:hover:[&:not(:has(input:is(:focus,[readonly],[aria-invalid=true])))]:border-muted-foreground",
59
+ "has-[input[readonly]]:bg-muted",
60
+ "has-[input:focus:not([aria-invalid=true])]:border-action has-[input:focus]:shadow-focus",
61
+ "has-[input[aria-invalid=true]]:border-destructive has-[input[aria-invalid=true]:focus]:shadow-focus-destructive",
45
62
  disabled && "pointer-events-none opacity-disabled",
46
63
  className,
47
64
  )}
@@ -49,9 +66,9 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
49
66
  <button
50
67
  type="button"
51
68
  aria-label="Decrease"
52
- disabled={disabled || (min != null && current <= min)}
69
+ disabled={disabled || readOnly || (min != null && current <= min)}
53
70
  onClick={() => set(current - step)}
54
- className="flex w-9 shrink-0 items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-disabled"
71
+ className="flex w-9 shrink-0 items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-disabled"
55
72
  >
56
73
  <Minus className="size-4" />
57
74
  </button>
@@ -64,19 +81,20 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
64
81
  max={max}
65
82
  step={step}
66
83
  disabled={disabled}
84
+ readOnly={readOnly}
67
85
  onChange={(e) => {
68
86
  const n = e.target.valueAsNumber;
69
87
  if (!Number.isNaN(n)) set(n);
70
88
  }}
71
- className="w-full min-w-0 flex-1 border-x border-input bg-transparent px-2 text-center text-body-md text-foreground outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
89
+ className="w-full min-w-0 flex-1 border-x border-input bg-transparent px-2 py-3 text-center text-body-md text-foreground outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
72
90
  {...props}
73
91
  />
74
92
  <button
75
93
  type="button"
76
94
  aria-label="Increase"
77
- disabled={disabled || (max != null && current >= max)}
95
+ disabled={disabled || readOnly || (max != null && current >= max)}
78
96
  onClick={() => set(current + step)}
79
- className="flex w-9 shrink-0 items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-disabled"
97
+ className="flex w-9 shrink-0 items-center justify-center text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-disabled"
80
98
  >
81
99
  <Plus className="size-4" />
82
100
  </button>
@@ -44,7 +44,7 @@ PaginationLink.displayName = "PaginationLink";
44
44
 
45
45
  const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
46
46
  <PaginationLink aria-label="Go to previous page" className={cn("w-auto gap-1 px-2.5", className)} {...props}>
47
- <ChevronLeft className="size-4" />
47
+ <ChevronLeft className="size-4 rtl:-scale-x-100" />
48
48
  <span>Previous</span>
49
49
  </PaginationLink>
50
50
  );
@@ -53,7 +53,7 @@ PaginationPrevious.displayName = "PaginationPrevious";
53
53
  const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
54
54
  <PaginationLink aria-label="Go to next page" className={cn("w-auto gap-1 px-2.5", className)} {...props}>
55
55
  <span>Next</span>
56
- <ChevronRight className="size-4" />
56
+ <ChevronRight className="size-4 rtl:-scale-x-100" />
57
57
  </PaginationLink>
58
58
  );
59
59
  PaginationNext.displayName = "PaginationNext";
@@ -3,7 +3,9 @@
3
3
  import * as React from "react";
4
4
  import * as PopoverPrimitive from "@radix-ui/react-popover";
5
5
  import { cn } from "../lib/utils";
6
+ import { withDirectionalPortal } from "./direction-provider";
6
7
 
8
+ const PopoverPortal = withDirectionalPortal(PopoverPrimitive.Portal);
7
9
  const Popover = PopoverPrimitive.Root;
8
10
  const PopoverTrigger = PopoverPrimitive.Trigger;
9
11
  const PopoverAnchor = PopoverPrimitive.Anchor;
@@ -11,21 +13,43 @@ const PopoverAnchor = PopoverPrimitive.Anchor;
11
13
  const PopoverContent = React.forwardRef<
12
14
  React.ElementRef<typeof PopoverPrimitive.Content>,
13
15
  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
14
- >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
15
- <PopoverPrimitive.Portal>
16
+ >(({ className, align = "center", sideOffset = 4, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, ...props }, ref) => (
17
+ <PopoverPortal>
16
18
  <PopoverPrimitive.Content
17
19
  ref={ref}
18
20
  align={align}
19
21
  sideOffset={sideOffset}
22
+ // Radix gives this `role="dialog"`, and a dialog without an accessible name is announced as just
23
+ // "dialog" — WCAG 4.1.2. Nothing here named it, so every Popover in the library shipped that way:
24
+ // found by running axe against the surface while it was open, which until this slice nothing did.
25
+ // The fallback is deliberately only a fallback. A caller who passes `aria-label` or points at a
26
+ // heading with `aria-labelledby` keeps theirs, and should: "Popover" tells a screen-reader user
27
+ // what kind of thing opened and nothing about what is in it. It is the floor, not the goal.
28
+ // Both naming props are destructured OUT of the spread, not read from it. Read from `props` with
29
+ // `{...props}` trailing, `aria-label={label}` with `label` undefined — the ordinary conditional-prop
30
+ // spelling — spreads the key back as undefined and deletes the attribute, so the fallback computed
31
+ // here would be erased and the dialog would ship unnamed again by the very pattern it guards.
32
+ aria-labelledby={ariaLabelledBy}
33
+ aria-label={ariaLabel ?? (ariaLabelledBy ? undefined : "Popover")}
20
34
  className={cn(
21
- "z-overlay w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none font-sans",
22
- "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",
35
+ // A surface wider than the window is not usable. Measured at 390px with the root font size
36
+ // doubled: this came out 576px, because `w-72` is rem and doubles with the text, and the
37
+ // context menu 416px — pushing the page 186px sideways, so a reader who asked for large text
38
+ // had to scroll horizontally to see a popover. Radix publishes the room it has as
39
+ // `--radix-popper-available-width`, so the cap is its own measurement rather than a viewport
40
+ // guess, and it binds only when the surface would otherwise overflow.
41
+ "z-overlay w-72 max-w-[var(--radix-popper-available-width)] rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none font-sans",
42
+ "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",
43
+ // `data-side` is the side Radix RESOLVED the surface onto — it is already physical, having been
44
+ // flipped for direction and collisions before it reaches the DOM. The slide must therefore stay
45
+ // physical to travel away from the trigger; a logical class here would invert the animation under
46
+ // RTL and make the surface fly the wrong way. // rtl-ok
23
47
  "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",
24
48
  className,
25
49
  )}
26
50
  {...props}
27
51
  />
28
- </PopoverPrimitive.Portal>
52
+ </PopoverPortal>
29
53
  ));
30
54
  PopoverContent.displayName = PopoverPrimitive.Content.displayName;
31
55
 
@@ -6,9 +6,13 @@ import { Circle } from "lucide-react";
6
6
  import { cn } from "../lib/utils";
7
7
 
8
8
  /**
9
- * RadioGroup — reconciled 1:1 with the KinetixUI design source, node 54863:536.
10
- * 18px circle, 2px border. Unchecked border = `--input`; checked border + dot =
11
- * `--primary`. Focus = 2px `--ring` offset. `aria-invalid` → `--destructive`.
9
+ * RadioGroup — from the KinetixUI design source, node 54863:536: 18px circle, 2px border, checked border +
10
+ * dot = `--action`, focus = 2px `--ring` offset.
11
+ *
12
+ * Each item follows the selection-control state contract (TOKENS.md, "Selection controls"), exactly as
13
+ * Checkbox does: a `--muted-foreground` unchecked edge that clears 3:1, an 8% `--foreground` state layer on
14
+ * hover (14% pressed), a dot as the shape cue for checked, `aria-invalid="true"` → `--destructive` edge and
15
+ * dot, a focus ring no other layer can cover, and no response at all while disabled.
12
16
  */
13
17
  const RadioGroup = React.forwardRef<
14
18
  React.ElementRef<typeof RadioGroupPrimitive.Root>,
@@ -25,17 +29,28 @@ const RadioGroupItem = React.forwardRef<
25
29
  <RadioGroupPrimitive.Item
26
30
  ref={ref}
27
31
  className={cn(
28
- "aspect-square size-[18px] rounded-full border-2 border-input text-action outline-none transition-colors",
29
- "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
30
- "disabled:cursor-not-allowed disabled:opacity-disabled",
32
+ // rem rather than px, for the reason spelled out in checkbox.tsx: at a 200% default font size the
33
+ // 18px dot did not grow while its label did.
34
+ "aspect-square size-[1.125rem] rounded-full border-2 border-muted-foreground text-action outline-none",
35
+ "transition-[color,border-color,box-shadow] duration-instant ease-standard",
31
36
  "data-[state=checked]:border-action",
32
- "aria-invalid:border-destructive",
37
+ // `aria-[invalid=true]:`, not `aria-invalid:`, which Tailwind 3 does not define (see checkbox.tsx).
38
+ // `text-` carries the dot, which is `fill-current`.
39
+ "aria-[invalid=true]:border-destructive aria-[invalid=true]:text-destructive",
40
+ "[@media(hover:hover)]:enabled:hover:[box-shadow:0_0_0_5px_hsl(var(--foreground)/0.08)]",
41
+ // Pressed is written twice: bare for touch, and again inside the hover media query, because Tailwind
42
+ // emits media-wrapped utilities after plain ones and the hover layer would otherwise outrank the press.
43
+ "enabled:active:[box-shadow:0_0_0_5px_hsl(var(--foreground)/0.14)]",
44
+ "[@media(hover:hover)]:enabled:active:[box-shadow:0_0_0_5px_hsl(var(--foreground)/0.14)]",
45
+ "[@media(hover:hover)]:enabled:hover:[&[data-state=unchecked]:not([aria-invalid=true])]:border-foreground",
46
+ "focus-visible:!ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
47
+ "disabled:cursor-not-allowed disabled:opacity-disabled",
33
48
  className,
34
49
  )}
35
50
  {...props}
36
51
  >
37
52
  <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
38
- <Circle className="size-2.5 fill-action text-action" />
53
+ <Circle className="size-2.5 fill-current" />
39
54
  </RadioGroupPrimitive.Indicator>
40
55
  </RadioGroupPrimitive.Item>
41
56
  ));
@@ -13,6 +13,16 @@ import { cn } from "../lib/utils";
13
13
  * is fixed, not exposed — a segmented control that could go fully
14
14
  * unselected or multi-select isn't the pattern. Gap-fill addition (not in
15
15
  * the original Figma source).
16
+ *
17
+ * Surfaces follow the surface model (TOKENS.md): the track is an inset well, `--surface-grouped`, and the
18
+ * chosen segment is a small raised surface on it — `--card`, the Card's half-strength `--border` edge and
19
+ * `sm` depth. The track used to be `--muted` with a `--background` segment, which in dark mode put the
20
+ * chosen segment BELOW its track (`--background` is the darkest surface), so it read as a hole.
21
+ *
22
+ * States (TOKENS.md, "Selection controls"): an unchosen segment answers the pointer with the same
23
+ * `--foreground` state layer the other selection controls use (8% hover, 14% pressed) and `--foreground`
24
+ * text; chosen is surface + edge + depth + `--foreground` text, so it is never colour alone; focus is the
25
+ * ring; disabled is inert.
16
26
  */
17
27
  export type SegmentedControlProps = Omit<ToggleGroupPrimitive.ToggleGroupSingleProps, "type">;
18
28
 
@@ -23,7 +33,7 @@ const SegmentedControl = React.forwardRef<
23
33
  <ToggleGroupPrimitive.Root
24
34
  ref={ref}
25
35
  type="single"
26
- className={cn("inline-flex items-center rounded-lg bg-muted p-1 font-sans", className)}
36
+ className={cn("inline-flex items-center rounded-lg bg-surface-grouped p-1 font-sans", className)}
27
37
  {...props}
28
38
  />
29
39
  ));
@@ -36,10 +46,16 @@ const SegmentedControlItem = React.forwardRef<
36
46
  <ToggleGroupPrimitive.Item
37
47
  ref={ref}
38
48
  className={cn(
39
- "inline-flex flex-1 items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-label-md font-medium text-muted-foreground transition-all",
40
- "outline-none focus-visible:ring-2 focus-visible:ring-ring",
49
+ "inline-flex flex-1 items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-label-md font-medium text-muted-foreground",
50
+ "transition-[color,background-color,box-shadow] duration-fast ease-standard",
51
+ "[@media(hover:hover)]:enabled:hover:data-[state=off]:bg-foreground/[0.08] [@media(hover:hover)]:enabled:hover:data-[state=off]:text-foreground",
52
+ // Pressed is written twice: bare for touch, and again inside the hover media query, because Tailwind
53
+ // emits media-wrapped utilities after plain ones and the hover layer would otherwise outrank the press.
54
+ "enabled:active:data-[state=off]:bg-foreground/[0.14] enabled:active:data-[state=off]:text-foreground",
55
+ "[@media(hover:hover)]:enabled:active:data-[state=off]:bg-foreground/[0.14] [@media(hover:hover)]:enabled:active:data-[state=off]:text-foreground",
56
+ "data-[state=on]:bg-card data-[state=on]:text-foreground data-[state=on]:[box-shadow:inset_0_0_0_1px_hsl(var(--border)/0.5),var(--shadow-sm)]",
57
+ "outline-none focus-visible:!ring-2 focus-visible:ring-ring",
41
58
  "disabled:pointer-events-none disabled:opacity-disabled",
42
- "data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm",
43
59
  className,
44
60
  )}
45
61
  {...props}
@@ -4,12 +4,16 @@ import * as React from "react";
4
4
  import * as SelectPrimitive from "@radix-ui/react-select";
5
5
  import { Check, ChevronDown, ChevronUp } from "lucide-react";
6
6
  import { cn } from "../lib/utils";
7
+ import { withDirectionalPortal } from "./direction-provider";
7
8
 
8
9
  /**
9
10
  * Select — reconciled 1:1 with the KinetixUI design source, node 54855:13882.
10
- * The trigger mirrors Input: `rounded-sm`, `border-input`, `--spacing-3` pad,
11
- * Body Medium text, a soft `--shadow-focus` glow, `--destructive` on aria-invalid.
11
+ * The trigger mirrors Input: `rounded-sm`, `--spacing-3` pad, Body Medium text, a soft `--shadow-focus`
12
+ * glow, `--destructive` on aria-invalid — and Input's text-entry state contract (TOKENS.md, "Text entry and
13
+ * navigation"): an 80% `--muted-foreground` edge at rest, full strength under a hovering pointer, and hover
14
+ * never over focus, an open list, invalid or disabled. Select has no read-only state.
12
15
  */
16
+ const SelectPortal = withDirectionalPortal(SelectPrimitive.Portal);
13
17
  const Select = SelectPrimitive.Root;
14
18
  const SelectGroup = SelectPrimitive.Group;
15
19
  const SelectValue = SelectPrimitive.Value;
@@ -21,10 +25,23 @@ const SelectTrigger = React.forwardRef<
21
25
  <SelectPrimitive.Trigger
22
26
  ref={ref}
23
27
  className={cn(
24
- "flex min-h-[44px] w-full items-center justify-between rounded-sm border border-input bg-background px-3 py-3",
28
+ "flex min-h-[44px] w-full items-center justify-between rounded-sm border border-muted-foreground/80 bg-background px-3 py-3",
25
29
  "font-sans text-body-md text-foreground",
26
- "data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1",
27
- "outline-none transition-colors",
30
+ // The value wraps rather than being clamped to one line. `line-clamp-1` was invisible while the
31
+ // type scale was px — the value always fitted — but once text follows the reader's setting a
32
+ // two-line value is cut off at 200% with no ellipsis (measured: span needs 80px, gets 40px, and
33
+ // computed `text-overflow` is `clip`, not `ellipsis`), so "Select a fruit" rendered as "Select a".
34
+ // The trigger is already `min-h`, so letting it grow is what it was built for, and it matches how
35
+ // Input and Textarea absorb their own text. WCAG 1.4.4 asks for no loss of content at 200%.
36
+ //
37
+ // `min-w-0` and `overflow-wrap: anywhere` are what make that safe. A flex child keeps its
38
+ // min-content width by default, so a value with no break opportunity — an identifier, a URL with
39
+ // no separators — would simply not shrink: measured at 282px inside a 220px trigger, overflowing
40
+ // it and pushing the chevron outside. `line-clamp-1` had been hiding that by clipping. Breaking
41
+ // the token is the behaviour that keeps both the value and the chevron inside the control.
42
+ "data-[placeholder]:text-muted-foreground [&>span]:min-w-0 [&>span]:[overflow-wrap:anywhere]",
43
+ "outline-none transition-[border-color,box-shadow,background-color] duration-instant ease-standard",
44
+ "[@media(hover:hover)]:enabled:hover:[&:not(:focus):not([aria-invalid=true]):not([data-state=open])]:border-muted-foreground",
28
45
  "focus:border-action focus:shadow-focus",
29
46
  "data-[state=open]:border-action",
30
47
  "aria-[invalid=true]:border-destructive aria-[invalid=true]:focus:border-destructive aria-[invalid=true]:focus:shadow-focus-destructive",
@@ -47,13 +64,13 @@ const SelectContent = React.forwardRef<
47
64
  React.ElementRef<typeof SelectPrimitive.Content>,
48
65
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
49
66
  >(({ className, children, position = "popper", ...props }, ref) => (
50
- <SelectPrimitive.Portal>
67
+ <SelectPortal>
51
68
  <SelectPrimitive.Content
52
69
  ref={ref}
53
70
  position={position}
54
71
  className={cn(
55
72
  "relative z-overlay max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md font-sans",
56
- "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
+ "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",
57
74
  position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1",
58
75
  className,
59
76
  )}
@@ -71,7 +88,7 @@ const SelectContent = React.forwardRef<
71
88
  <ChevronDown className="size-4" />
72
89
  </SelectPrimitive.ScrollDownButton>
73
90
  </SelectPrimitive.Content>
74
- </SelectPrimitive.Portal>
91
+ </SelectPortal>
75
92
  ));
76
93
  SelectContent.displayName = SelectPrimitive.Content.displayName;
77
94
 
@@ -5,11 +5,12 @@ import * as SheetPrimitive from "@radix-ui/react-dialog";
5
5
  import { cva, type VariantProps } from "class-variance-authority";
6
6
  import { X } from "lucide-react";
7
7
  import { cn } from "../lib/utils";
8
+ import { withDirectionalPortal } from "./direction-provider";
8
9
 
9
10
  const Sheet = SheetPrimitive.Root;
10
11
  const SheetTrigger = SheetPrimitive.Trigger;
11
12
  const SheetClose = SheetPrimitive.Close;
12
- const SheetPortal = SheetPrimitive.Portal;
13
+ const SheetPortal = withDirectionalPortal(SheetPrimitive.Portal);
13
14
 
14
15
  const SheetOverlay = React.forwardRef<
15
16
  React.ElementRef<typeof SheetPrimitive.Overlay>,
@@ -18,7 +19,7 @@ const SheetOverlay = React.forwardRef<
18
19
  <SheetPrimitive.Overlay
19
20
  ref={ref}
20
21
  className={cn(
21
- "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",
22
+ "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",
22
23
  className,
23
24
  )}
24
25
  {...props}
@@ -73,7 +74,7 @@ const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElemen
73
74
  SheetHeader.displayName = "SheetHeader";
74
75
 
75
76
  const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
76
- <div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
77
+ <div className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />
77
78
  );
78
79
  SheetFooter.displayName = "SheetFooter";
79
80
 
@@ -9,7 +9,7 @@ import { cn } from "../lib/utils";
9
9
  * dependency; `size` sets the diameter, `variant` swaps the indicator
10
10
  * colour for on-color surfaces.
11
11
  */
12
- const spinnerVariants = cva("inline-block animate-spin motion-reduce:animate-[spin_3s_linear_infinite] rounded-full border-2 border-current border-t-transparent", {
12
+ const spinnerVariants = cva("inline-block animate-spin motion-reduce:animate-none rounded-full border-2 border-current border-t-transparent forced-colors:border-t-[Canvas]", {
13
13
  variants: {
14
14
  size: {
15
15
  sm: "size-4",
@@ -5,9 +5,14 @@ import * as SwitchPrimitive from "@radix-ui/react-switch";
5
5
  import { cn } from "../lib/utils";
6
6
 
7
7
  /**
8
- * Switch — reconciled 1:1 with the KinetixUI design source, node 54855:13984.
9
- * 48×24 pill track. Off = `--tertiary` (#b0b0b0), on = `--primary`. 20px
10
- * `--background` thumb, travels 24px. Focus = 2px `--ring` offset.
8
+ * Switch — from the KinetixUI design source, node 54855:13984: 48×24 pill track, off = `--tertiary`, on =
9
+ * `--action`, 20px `--background` thumb that travels 24px, focus = 2px `--ring` offset.
10
+ *
11
+ * It follows the selection-control state contract (TOKENS.md, "Selection controls"): the same 8% / 14%
12
+ * `--foreground` state layer as Checkbox and Radio on hover and press, the on track steps to `--action`/90
13
+ * under the pointer, the thumb's position is the shape cue for on/off, the focus ring cannot be covered by
14
+ * any other layer, and a disabled switch does not answer the pointer. The off track stays `--tertiary`
15
+ * (2.6:1, a tracked SC 1.4.11 exception in check-contrast.mjs): the thumb and `aria-checked` carry it.
11
16
  */
12
17
  const Switch = React.forwardRef<
13
18
  React.ElementRef<typeof SwitchPrimitive.Root>,
@@ -16,18 +21,36 @@ const Switch = React.forwardRef<
16
21
  <SwitchPrimitive.Root
17
22
  ref={ref}
18
23
  className={cn(
19
- "peer inline-flex h-6 w-12 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors",
20
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
21
- "disabled:cursor-not-allowed disabled:opacity-disabled",
24
+ "peer inline-flex h-6 w-12 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent",
25
+ "transition-[background-color,box-shadow] duration-instant ease-standard",
22
26
  "data-[state=checked]:bg-action data-[state=unchecked]:bg-tertiary",
27
+ "[@media(hover:hover)]:enabled:hover:[box-shadow:0_0_0_5px_hsl(var(--foreground)/0.08)]",
28
+ // Pressed is written twice: bare for touch, and again inside the hover media query, because Tailwind
29
+ // emits media-wrapped utilities after plain ones and the hover layer would otherwise outrank the press.
30
+ "enabled:active:[box-shadow:0_0_0_5px_hsl(var(--foreground)/0.14)]",
31
+ "[@media(hover:hover)]:enabled:active:[box-shadow:0_0_0_5px_hsl(var(--foreground)/0.14)]",
32
+ "[@media(hover:hover)]:enabled:hover:data-[state=checked]:bg-action/90",
33
+ "focus-visible:outline-none focus-visible:!ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
34
+ "disabled:cursor-not-allowed disabled:opacity-disabled",
23
35
  className,
24
36
  )}
25
37
  {...props}
26
38
  >
27
39
  <SwitchPrimitive.Thumb
28
40
  className={cn(
29
- "pointer-events-none block size-5 rounded-full bg-background shadow-lg ring-0 transition-transform",
30
- "data-[state=checked]:translate-x-6 data-[state=unchecked]:translate-x-0",
41
+ "pointer-events-none relative block size-5 rounded-full bg-background shadow-lg ring-0",
42
+ // The thumb travels along the INLINE axis of the switch's own direction: `inset-inline-start` is
43
+ // logical, so the browser resolves start against the direction the switch is laid out in — the
44
+ // nearest `dir`, in an LTR section of an RTL page as much as anywhere else. It used to be a physical
45
+ // `translate-x` flipped by `rtl:`, and Tailwind 3's `rtl:` is `[dir=rtl] *` — ANY rtl ancestor — so
46
+ // a switch in an LTR section of an RTL page was flipped too and its thumb landed 22px outside the
47
+ // track, over its own focus ring (check:selection-visual, "direction"). `:dir()` would have been the
48
+ // selector answer, but Vite 8's Lightning CSS lowers `:dir(ltr)` for its default targets to a
49
+ // `:not(:lang(ar, he, …))` guess about the page's LANGUAGE, which broke every RTL switch in the built
50
+ // Storybook — a consumer's toolchain can do the same. A logical property needs no selector at all.
51
+ // The offset is relative, so nothing around the switch reflows while it moves.
52
+ "transition-[inset-inline-start] duration-instant ease-standard",
53
+ "start-0 data-[state=checked]:start-6",
31
54
  )}
32
55
  />
33
56
  </SwitchPrimitive.Root>