@elabs-ai/components-ui 4.1.0 → 4.2.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 (193) hide show
  1. package/dist/{chunk-JKPPTL63.js → chunk-FCH4ZN6G.js} +12 -3
  2. package/dist/chunk-FCH4ZN6G.js.map +1 -0
  3. package/dist/form.js +3 -3
  4. package/dist/form.js.map +1 -1
  5. package/dist/index.d.ts +126 -35
  6. package/dist/index.js +3346 -2079
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/cn.js +10 -1
  9. package/dist/lib/cn.js.map +1 -1
  10. package/package.json +3 -3
  11. package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
  12. package/src/__contract__/badge.contract.test.tsx +49 -0
  13. package/src/__contract__/bounded-number.contract.test.tsx +49 -0
  14. package/src/__contract__/button.contract.test.tsx +49 -0
  15. package/src/__contract__/color-picker.contract.test.tsx +49 -0
  16. package/src/__contract__/combobox.contract.test.tsx +49 -0
  17. package/src/__contract__/command-trigger.contract.test.tsx +49 -0
  18. package/src/__contract__/copyable-value.contract.test.tsx +49 -0
  19. package/src/__contract__/date-picker.contract.test.tsx +49 -0
  20. package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
  21. package/src/__contract__/empty-state.contract.test.tsx +49 -0
  22. package/src/__contract__/error-state.contract.test.tsx +49 -0
  23. package/src/__contract__/icon-button.contract.test.tsx +49 -0
  24. package/src/__contract__/input.contract.test.tsx +49 -0
  25. package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
  26. package/src/__contract__/list-editor.contract.test.tsx +49 -0
  27. package/src/__contract__/loading-state.contract.test.tsx +49 -0
  28. package/src/__contract__/metric-card.contract.test.tsx +49 -0
  29. package/src/__contract__/model-picker.contract.test.tsx +49 -0
  30. package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
  31. package/src/__contract__/number-input.contract.test.tsx +49 -0
  32. package/src/__contract__/rating.contract.test.tsx +49 -0
  33. package/src/__contract__/schema-form.contract.test.tsx +49 -0
  34. package/src/__contract__/section-header.contract.test.tsx +49 -0
  35. package/src/__contract__/segmented-field.contract.test.tsx +49 -0
  36. package/src/__contract__/slider-number.contract.test.tsx +49 -0
  37. package/src/__contract__/status-badge.contract.test.tsx +49 -0
  38. package/src/__contract__/tag-input.contract.test.tsx +49 -0
  39. package/src/__contract__/text.contract.test.tsx +49 -0
  40. package/src/__contract__/textarea.contract.test.tsx +49 -0
  41. package/src/__contract__/timeline.contract.test.tsx +49 -0
  42. package/src/__contract__/toggle.contract.test.tsx +49 -0
  43. package/src/__contract__/transfer.contract.test.tsx +49 -0
  44. package/src/__contract__/tree-select.contract.test.tsx +49 -0
  45. package/src/__contract__/tree.contract.test.tsx +49 -0
  46. package/src/__contract__/virtual-select.contract.test.tsx +49 -0
  47. package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
  48. package/src/components/accordion/accordion.tsx +13 -4
  49. package/src/components/alert/alert.tsx +11 -3
  50. package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
  51. package/src/components/alert-dialog/alert-dialog.tsx +28 -6
  52. package/src/components/app-shell/app-shell.test.tsx +99 -0
  53. package/src/components/app-shell/app-shell.tsx +29 -10
  54. package/src/components/attribution-panel/attribution-panel.tsx +2 -0
  55. package/src/components/attribution-panel/attributions.generated.ts +2 -2
  56. package/src/components/avatar/avatar.tsx +4 -1
  57. package/src/components/badge/badge.test.tsx +8 -0
  58. package/src/components/badge/badge.tsx +14 -4
  59. package/src/components/bento-grid/bento-grid.test.tsx +23 -2
  60. package/src/components/bento-grid/bento-grid.tsx +10 -0
  61. package/src/components/bounded-number/bounded-number.test.tsx +1 -1
  62. package/src/components/bounded-number/bounded-number.tsx +78 -75
  63. package/src/components/breadcrumb/breadcrumb.tsx +3 -2
  64. package/src/components/button/button.tsx +18 -12
  65. package/src/components/button-group/button-group.tsx +1 -1
  66. package/src/components/calendar/calendar.tsx +3 -2
  67. package/src/components/card/card.test.tsx +24 -2
  68. package/src/components/card/card.tsx +47 -6
  69. package/src/components/carousel/carousel.test.tsx +169 -0
  70. package/src/components/carousel/carousel.tsx +93 -32
  71. package/src/components/change-review/change-review.tsx +38 -29
  72. package/src/components/checkbox/checkbox.tsx +1 -0
  73. package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
  74. package/src/components/color-picker/color-picker.tsx +17 -11
  75. package/src/components/combobox/combobox.stories.tsx +10 -0
  76. package/src/components/combobox/combobox.test.tsx +54 -0
  77. package/src/components/combobox/combobox.tsx +25 -11
  78. package/src/components/command/command.test.tsx +15 -0
  79. package/src/components/command/command.tsx +8 -6
  80. package/src/components/context-menu/context-menu.tsx +16 -7
  81. package/src/components/context-rail/context-rail.tsx +4 -4
  82. package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
  83. package/src/components/copyable-value/copyable-value.test.tsx +28 -1
  84. package/src/components/copyable-value/copyable-value.tsx +12 -1
  85. package/src/components/date-picker/date-picker.stories.tsx +9 -0
  86. package/src/components/date-picker/date-picker.test.tsx +52 -0
  87. package/src/components/date-picker/date-picker.tsx +13 -15
  88. package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
  89. package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
  90. package/src/components/date-range-picker/date-range-picker.tsx +39 -28
  91. package/src/components/descriptions/descriptions.tsx +1 -1
  92. package/src/components/dialog/dialog.test.tsx +8 -3
  93. package/src/components/dialog/dialog.tsx +12 -4
  94. package/src/components/drawer/drawer.tsx +23 -6
  95. package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
  96. package/src/components/field/field.tsx +2 -0
  97. package/src/components/field-row/field-row.tsx +2 -0
  98. package/src/components/file-upload/file-upload.test.tsx +83 -10
  99. package/src/components/file-upload/file-upload.tsx +150 -28
  100. package/src/components/form/form.tsx +4 -2
  101. package/src/components/hover-card/hover-card.tsx +1 -0
  102. package/src/components/icon-button/icon-button.tsx +1 -1
  103. package/src/components/input/input.tsx +7 -3
  104. package/src/components/input-group/input-group.tsx +6 -6
  105. package/src/components/input-otp/input-otp.tsx +3 -1
  106. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +2 -2
  107. package/src/components/label/label.tsx +1 -1
  108. package/src/components/list-editor/list-editor.test.tsx +24 -0
  109. package/src/components/list-editor/list-editor.tsx +126 -52
  110. package/src/components/locale-provider/locale-keys.test.ts +134 -0
  111. package/src/components/locale-provider/messages.ts +321 -0
  112. package/src/components/mention-input/mention-input.stories.tsx +3 -3
  113. package/src/components/mention-input/mention-input.test.tsx +51 -1
  114. package/src/components/mention-input/mention-input.tsx +58 -8
  115. package/src/components/menubar/menubar.tsx +16 -6
  116. package/src/components/nav-main/nav-main.tsx +7 -3
  117. package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
  118. package/src/components/nav-notifications/nav-notifications.tsx +11 -5
  119. package/src/components/nav-user/nav-user.tsx +5 -5
  120. package/src/components/navigation-menu/navigation-menu.tsx +16 -4
  121. package/src/components/number-input/number-input.stories.tsx +7 -3
  122. package/src/components/number-input/number-input.test.tsx +47 -0
  123. package/src/components/number-input/number-input.tsx +65 -24
  124. package/src/components/pagination/pagination.test.tsx +20 -1
  125. package/src/components/pagination/pagination.tsx +18 -14
  126. package/src/components/popover/popover.tsx +1 -0
  127. package/src/components/progress/progress.tsx +2 -0
  128. package/src/components/rating/rating.tsx +2 -0
  129. package/src/components/resizable/resizable.test.tsx +76 -0
  130. package/src/components/resizable/resizable.tsx +31 -5
  131. package/src/components/revision-timeline/revision-timeline.tsx +7 -2
  132. package/src/components/schema-form/schema-form-store.ts +506 -0
  133. package/src/components/schema-form/schema-form.test.tsx +122 -1
  134. package/src/components/schema-form/schema-form.tsx +163 -327
  135. package/src/components/section-header/section-header.tsx +2 -2
  136. package/src/components/select/select.tsx +7 -5
  137. package/src/components/separator/separator.tsx +1 -0
  138. package/src/components/sheet/sheet.test.tsx +39 -0
  139. package/src/components/sheet/sheet.tsx +40 -9
  140. package/src/components/side-dock/side-dock.test.tsx +26 -0
  141. package/src/components/side-dock/side-dock.tsx +15 -2
  142. package/src/components/sidebar/sidebar.test.tsx +35 -1
  143. package/src/components/sidebar/sidebar.tsx +20 -6
  144. package/src/components/skeleton/skeleton.tsx +1 -0
  145. package/src/components/slider-number/slider-number.stories.tsx +4 -1
  146. package/src/components/slider-number/slider-number.test.tsx +1 -1
  147. package/src/components/sonner/sonner.tsx +2 -0
  148. package/src/components/spinner/spinner.test.tsx +18 -0
  149. package/src/components/spinner/spinner.tsx +14 -4
  150. package/src/components/state-panel/state-panel.test.tsx +3 -3
  151. package/src/components/state-panel/state-panel.tsx +15 -8
  152. package/src/components/switch/switch.tsx +2 -0
  153. package/src/components/table/table.stories.tsx +3 -1
  154. package/src/components/table/table.test.tsx +9 -2
  155. package/src/components/table/table.tsx +18 -8
  156. package/src/components/tabs/index.ts +10 -1
  157. package/src/components/tabs/tabs.stories.tsx +48 -0
  158. package/src/components/tabs/tabs.test.tsx +67 -0
  159. package/src/components/tabs/tabs.tsx +175 -77
  160. package/src/components/tag-input/tag-input.tsx +4 -2
  161. package/src/components/team-switcher/team-switcher.tsx +3 -3
  162. package/src/components/textarea/textarea.tsx +1 -1
  163. package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
  164. package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
  165. package/src/components/theme-switcher/theme-switcher.tsx +134 -10
  166. package/src/components/timeline/timeline.tsx +0 -3
  167. package/src/components/toggle/toggle.tsx +7 -2
  168. package/src/components/toggle-group/toggle-group.tsx +2 -0
  169. package/src/components/tooltip/tooltip.tsx +2 -1
  170. package/src/components/top-nav/top-nav.tsx +1 -1
  171. package/src/components/transfer/transfer.tsx +35 -30
  172. package/src/components/tree/tree.test.tsx +41 -0
  173. package/src/components/tree/tree.tsx +77 -22
  174. package/src/components/tree/use-tree-keyboard.ts +37 -14
  175. package/src/components/tree-select/tree-select.stories.tsx +9 -0
  176. package/src/components/tree-select/tree-select.test.tsx +5 -0
  177. package/src/components/tree-select/tree-select.tsx +11 -14
  178. package/src/components/typography/prose.test.tsx +3 -2
  179. package/src/components/typography/prose.tsx +3 -3
  180. package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
  181. package/src/components/virtual-select/virtual-select.test.tsx +28 -2
  182. package/src/components/virtual-select/virtual-select.tsx +56 -18
  183. package/src/components/wizard/wizard.test.tsx +9 -0
  184. package/src/components/wizard/wizard.tsx +12 -4
  185. package/src/index.ts +5 -0
  186. package/src/lib/cn.ts +9 -0
  187. package/src/lib/csv.ts +48 -0
  188. package/src/lib/diff-rows.ts +2 -0
  189. package/src/lib/merge-refs.ts +2 -0
  190. package/src/lib/use-controllable-state.ts +47 -0
  191. package/src/lib/use-mobile.test.tsx +47 -0
  192. package/src/lib/use-mobile.ts +26 -10
  193. package/dist/chunk-JKPPTL63.js.map +0 -1
@@ -28,83 +28,86 @@ export interface BoundedNumberProps extends NumberInputProps {
28
28
  * `null` AND the field is not focused, so the underlying input stays fully
29
29
  * clickable/typable and still round-trips to `null` on an empty blur.
30
30
  */
31
- export const BoundedNumber = forwardRef<HTMLDivElement, BoundedNumberProps>(function BoundedNumber(
32
- {
33
- value: valueProp,
34
- defaultValue,
35
- onValueChange,
36
- emptyLabel,
37
- onFocus,
38
- onBlur,
39
- className,
40
- ...props
41
- },
42
- ref,
43
- ) {
44
- const { t } = useLocale();
45
- const resolvedEmptyLabel = emptyLabel ?? t("ui.boundedNumber.emptyLabel");
46
- const isControlled = valueProp !== undefined;
47
- const [internalValue, setInternalValue] = useState<number | null>(
48
- defaultValue !== undefined ? defaultValue : null,
49
- );
50
- const [focused, setFocused] = useState(false);
51
- const currentValue = isControlled ? valueProp : internalValue;
52
-
53
- const handleValueChange = useCallback(
54
- (next: number | null) => {
55
- if (!isControlled) setInternalValue(next);
56
- onValueChange?.(next);
31
+ export const BoundedNumber = forwardRef<HTMLInputElement, BoundedNumberProps>(
32
+ function BoundedNumber(
33
+ {
34
+ value: valueProp,
35
+ defaultValue,
36
+ onValueChange,
37
+ emptyLabel,
38
+ onFocus,
39
+ onBlur,
40
+ className,
41
+ ...props
57
42
  },
58
- [isControlled, onValueChange],
59
- );
43
+ ref,
44
+ ) {
45
+ const { t } = useLocale();
46
+ const resolvedEmptyLabel = emptyLabel ?? t("ui.boundedNumber.emptyLabel");
47
+ const isControlled = valueProp !== undefined;
48
+ const [internalValue, setInternalValue] = useState<number | null>(
49
+ defaultValue !== undefined ? defaultValue : null,
50
+ );
51
+ const [focused, setFocused] = useState(false);
52
+ const currentValue = isControlled ? valueProp : internalValue;
60
53
 
61
- const handleFocus = useCallback(
62
- (event: FocusEvent<HTMLDivElement>) => {
63
- setFocused(true);
64
- onFocus?.(event);
65
- },
66
- [onFocus],
67
- );
54
+ const handleValueChange = useCallback(
55
+ (next: number | null) => {
56
+ if (!isControlled) setInternalValue(next);
57
+ onValueChange?.(next);
58
+ },
59
+ [isControlled, onValueChange],
60
+ );
68
61
 
69
- const handleBlur = useCallback(
70
- (event: FocusEvent<HTMLDivElement>) => {
71
- setFocused(false);
72
- onBlur?.(event);
73
- },
74
- [onBlur],
75
- );
62
+ const handleFocus = useCallback(
63
+ (event: FocusEvent<HTMLDivElement>) => {
64
+ setFocused(true);
65
+ onFocus?.(event);
66
+ },
67
+ [onFocus],
68
+ );
69
+
70
+ const handleBlur = useCallback(
71
+ (event: FocusEvent<HTMLDivElement>) => {
72
+ setFocused(false);
73
+ onBlur?.(event);
74
+ },
75
+ [onBlur],
76
+ );
76
77
 
77
- const showEmptyLabel = currentValue == null && !focused;
78
+ const showEmptyLabel = currentValue == null && !focused;
78
79
 
79
- return (
80
- // `className` lands on the ROOT (the positioning context), not on the
81
- // inner `NumberInput`: a width utility on the inner control would resolve
82
- // against a shrink-to-fit wrapper and silently do nothing, so
83
- // `className="w-full"` could never widen the field. `w-36` here is
84
- // `NumberInput`'s own default width, re-declared so `cn()` lets the caller
85
- // override it; the input then simply fills the root.
86
- <div className={cn("relative inline-block w-36", className)} data-slot="bounded-number">
87
- <NumberInput
88
- ref={ref}
89
- value={currentValue}
90
- onValueChange={handleValueChange}
91
- onFocus={handleFocus}
92
- onBlur={handleBlur}
93
- className="w-full"
94
- {...props}
95
- />
96
- {showEmptyLabel && (
97
- <span
98
- aria-hidden="true"
99
- data-slot="bounded-number-empty-label"
100
- className={cn(
101
- "pointer-events-none absolute inset-0 flex items-center justify-center",
102
- "text-body text-muted-foreground",
103
- )}
104
- >
105
- {resolvedEmptyLabel}
106
- </span>
107
- )}
108
- </div>
109
- );
110
- });
80
+ return (
81
+ // `className` lands on the ROOT (the positioning context), not on the
82
+ // inner `NumberInput`: a width utility on the inner control would resolve
83
+ // against a shrink-to-fit wrapper and silently do nothing, so
84
+ // `className="w-full"` could never widen the field. `w-36` is this
85
+ // preset's own default width (`NumberInput` itself is unconstrained and
86
+ // fills its container) — `cn()` lets the caller override it, and the
87
+ // inner `NumberInput` simply fills the root via `className="w-full"`.
88
+ <div className={cn("relative inline-block w-36", className)} data-slot="bounded-number">
89
+ <NumberInput
90
+ ref={ref}
91
+ value={currentValue}
92
+ onValueChange={handleValueChange}
93
+ onFocus={handleFocus}
94
+ onBlur={handleBlur}
95
+ className="w-full"
96
+ {...props}
97
+ />
98
+ {showEmptyLabel && (
99
+ <span
100
+ aria-hidden="true"
101
+ data-slot="bounded-number-empty-label"
102
+ className={cn(
103
+ "pointer-events-none absolute inset-0 flex items-center justify-center",
104
+ "text-body text-muted-foreground",
105
+ )}
106
+ >
107
+ {resolvedEmptyLabel}
108
+ </span>
109
+ )}
110
+ </div>
111
+ );
112
+ },
113
+ );
@@ -6,7 +6,8 @@ import { useLocale } from "../locale-provider";
6
6
 
7
7
  export const Breadcrumb = forwardRef<HTMLElement, ComponentProps<"nav">>(
8
8
  function Breadcrumb(props, ref) {
9
- return <nav ref={ref} aria-label="breadcrumb" {...props} />;
9
+ const { t } = useLocale();
10
+ return <nav ref={ref} aria-label={t("ui.breadcrumb.label")} {...props} />;
10
11
  },
11
12
  );
12
13
  export const BreadcrumbList = forwardRef<HTMLOListElement, ComponentProps<"ol">>(
@@ -15,7 +16,7 @@ export const BreadcrumbList = forwardRef<HTMLOListElement, ComponentProps<"ol">>
15
16
  <ol
16
17
  ref={ref}
17
18
  className={cn(
18
- "flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground",
19
+ "flex flex-wrap items-center gap-1.5 break-words text-body text-muted-foreground",
19
20
  className,
20
21
  )}
21
22
  {...props}
@@ -4,15 +4,21 @@ import { cva, type VariantProps } from "class-variance-authority";
4
4
  import { cn } from "../../lib/cn";
5
5
 
6
6
  export const buttonVariants = cva(
7
- "inline-flex items-center justify-center gap-2 whitespace-nowrap touch-manipulation rounded-md text-body font-medium transition-[color,background-color,border-color,box-shadow,scale] duration-fast ease-standard active:scale-[0.98] motion-reduce:active:scale-100 focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
7
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap touch-manipulation rounded-control text-body font-control transition-[color,background-color,border-color,box-shadow,scale] duration-fast ease-standard active:scale-[0.98] motion-reduce:active:scale-100 focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
8
8
  {
9
9
  variants: {
10
10
  variant: {
11
+ // Disabled treatment is ONE rule for every variant, inherited from the
12
+ // base string above (`disabled:pointer-events-none disabled:opacity-50`)
13
+ // — no per-variant fill swap. A solid-fill variant fading via opacity
14
+ // still reads as "off" against `--muted`/`--background` in both themes;
15
+ // keeping one mechanism (vs. a bg-muted swap on some variants only) is
16
+ // what made Input's disabled state internally inconsistent (#286).
11
17
  default:
12
- "bg-primary text-primary-foreground hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100",
18
+ "bg-primary text-primary-foreground hover:bg-primary-hover active:bg-primary-active",
13
19
  secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
14
20
  destructive:
15
- "bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100",
21
+ "bg-destructive text-destructive-foreground hover:bg-destructive-hover active:bg-destructive-active",
16
22
  outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17
23
  // The calm "outlined but quiet" rung (#194, research 02 §3a). `outline` uses
18
24
  // the form-field `border-input` token, `outline-subtle` uses `border-border`.
@@ -24,17 +30,17 @@ export const buttonVariants = cva(
24
30
  "outline-subtle":
25
31
  "border border-border bg-background hover:bg-accent hover:text-accent-foreground",
26
32
  ghost: "hover:bg-accent hover:text-accent-foreground",
27
- // #399 — a link button is TEXT on the page, so it takes the on-surface
28
- // `-text` rung; `bg-primary` above keeps the fill rung.
29
- link: "text-primary-text underline-offset-4 hover:underline",
33
+ // #399 — a link button is TEXT on the page: `--link` defaults to the
34
+ // on-surface `--primary-text` rung; `bg-primary` above keeps the fill rung.
35
+ link: "text-link underline-offset-4 hover:underline",
30
36
  },
31
37
  size: {
32
- sm: "h-8 rounded-md px-3 text-meta",
33
- default: "h-9 px-4 py-2",
34
- lg: "h-10 rounded-md px-6",
35
- icon: "size-9",
36
- "icon-sm": "size-8",
37
- "icon-lg": "size-10",
38
+ sm: "h-control-sm px-3 text-meta",
39
+ default: "h-control px-4 py-2",
40
+ lg: "h-control-lg px-6",
41
+ icon: "size-control",
42
+ "icon-sm": "size-control-sm",
43
+ "icon-lg": "size-control-lg",
38
44
  },
39
45
  },
40
46
  defaultVariants: { variant: "default", size: "default" },
@@ -44,7 +44,7 @@ export const ButtonGroupText = forwardRef<
44
44
  <Comp
45
45
  ref={ref}
46
46
  className={cn(
47
- "flex items-center gap-2 rounded-md border bg-muted px-4 text-sm font-medium shadow-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
47
+ "flex items-center gap-2 rounded-md border bg-muted px-4 text-body font-medium shadow-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
48
48
  className,
49
49
  )}
50
50
  {...props}
@@ -14,6 +14,7 @@ export function Calendar({
14
14
  }: CalendarProps) {
15
15
  return (
16
16
  <DayPicker
17
+ data-slot="calendar"
17
18
  showOutsideDays={showOutsideDays}
18
19
  className={cn("p-3", className)}
19
20
  classNames={{
@@ -26,7 +27,7 @@ export function Calendar({
26
27
  // for a taller caption (e.g. `captionLayout="dropdown"`), and `px-8` keeps
27
28
  // the label clear of the buttons overlaying either edge.
28
29
  month_caption: "flex min-h-7 items-center justify-center px-8",
29
- caption_label: "text-sm font-medium",
30
+ caption_label: "text-body font-medium",
30
31
  // Overlays the caption row and pushes the buttons to either edge; the
31
32
  // buttons themselves stay in normal flow inside it.
32
33
  nav: "absolute inset-x-0 top-0 flex h-7 items-center justify-between",
@@ -42,7 +43,7 @@ export function Calendar({
42
43
  weekdays: "flex",
43
44
  weekday: "w-8 rounded-md text-[0.8rem] font-normal text-muted-foreground",
44
45
  week: "mt-2 flex w-full",
45
- day: "relative size-8 p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-e-md first:[&:has([aria-selected])]:rounded-s-md last:[&:has([aria-selected])]:rounded-e-md",
46
+ day: "relative size-8 p-0 text-center text-body focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-e-md first:[&:has([aria-selected])]:rounded-s-md last:[&:has([aria-selected])]:rounded-e-md",
46
47
  day_button: cn(
47
48
  buttonVariants({ variant: "ghost" }),
48
49
  "size-8 p-0 font-normal aria-selected:opacity-100",
@@ -1,5 +1,5 @@
1
- import { describe, expect, it } from "vitest";
2
- import { render, screen } from "@testing-library/react";
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
3
  import { Card, CardDescription, CardTitle } from "./card";
4
4
 
5
5
  describe("Card", () => {
@@ -103,6 +103,28 @@ describe("Card", () => {
103
103
  expect(region).toHaveAttribute("aria-hidden", "true");
104
104
  });
105
105
 
106
+ it("with `detail` + hover, cancels its pending conceal rAF on unmount", () => {
107
+ const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockReturnValue(42);
108
+ const cafSpy = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {});
109
+ const { unmount } = render(
110
+ <Card data-testid="card" detailReveal="hover" detail={<button>More</button>}>
111
+ main
112
+ </Card>,
113
+ );
114
+ const card = screen.getByTestId("card");
115
+ // focus-out schedules the conceal rAF this test is locking cleanup for.
116
+ fireEvent.focusOut(card);
117
+ expect(rafSpy).toHaveBeenCalled();
118
+
119
+ unmount();
120
+ // Without cancelling it, the scheduled frame is still pending after
121
+ // teardown and fires against a component that no longer exists.
122
+ expect(cafSpy).toHaveBeenCalledWith(42);
123
+
124
+ rafSpy.mockRestore();
125
+ cafSpy.mockRestore();
126
+ });
127
+
106
128
  it("with `detail` + hover, detail region is labelled (landmark navigation, #141)", () => {
107
129
  render(
108
130
  <Card detailReveal="hover" detail={<span>info</span>} detailLabel="Card details">
@@ -2,6 +2,7 @@
2
2
 
3
3
  import {
4
4
  forwardRef,
5
+ useEffect,
5
6
  useRef,
6
7
  useState,
7
8
  useCallback,
@@ -110,12 +111,22 @@ function CardWithHoverDetail({
110
111
  }: CardProps) {
111
112
  const [isRevealed, setIsRevealed] = useState(false);
112
113
  const containerRef = useRef<HTMLDivElement>(null);
114
+ const concealRafRef = useRef<number | null>(null);
115
+
116
+ useEffect(() => {
117
+ return () => {
118
+ if (concealRafRef.current !== null) cancelAnimationFrame(concealRafRef.current);
119
+ };
120
+ }, []);
113
121
 
114
122
  const reveal = useCallback(() => setIsRevealed(true), []);
115
123
  const conceal = useCallback(() => {
116
124
  // Only conceal if focus has truly left the card (not moved to detail).
117
125
  // Use requestAnimationFrame so the new focusIn event fires first.
118
- requestAnimationFrame(() => {
126
+ // Tracked in a ref so it can be cancelled if the card unmounts before the
127
+ // frame fires (a focus-out right before navigation away, say).
128
+ concealRafRef.current = requestAnimationFrame(() => {
129
+ concealRafRef.current = null;
119
130
  if (containerRef.current && !containerRef.current.contains(document.activeElement)) {
120
131
  setIsRevealed(false);
121
132
  }
@@ -142,6 +153,7 @@ function CardWithHoverDetail({
142
153
  return (
143
154
  <div
144
155
  ref={containerRef}
156
+ data-slot="card"
145
157
  className={cn(
146
158
  cardVariants({ interactive }),
147
159
  cardDetailVariants({ detailPlacement, detailReveal: "hover" }),
@@ -161,6 +173,7 @@ function CardWithHoverDetail({
161
173
  role="region"
162
174
  aria-label={detailLabel}
163
175
  aria-hidden={!isRevealed}
176
+ data-slot="card-detail"
164
177
  className={cn("overflow-hidden bg-background p-6", dividerEdge, dividerColor)}
165
178
  >
166
179
  {detail}
@@ -189,6 +202,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(function Card(
189
202
  return (
190
203
  <div
191
204
  ref={ref}
205
+ data-slot="card"
192
206
  className={cn(cardVariants({ interactive }), className)}
193
207
  style={style}
194
208
  {...props}
@@ -230,6 +244,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(function Card(
230
244
  return (
231
245
  <div
232
246
  ref={ref}
247
+ data-slot="card"
233
248
  className={cn(
234
249
  cardVariants({ interactive }),
235
250
  cardDetailVariants({ detailPlacement, detailReveal }),
@@ -243,6 +258,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(function Card(
243
258
  <div
244
259
  role="region"
245
260
  aria-label={detailLabel}
261
+ data-slot="card-detail"
246
262
  className={cn("overflow-hidden bg-background p-6", dividerEdge, dividerColor)}
247
263
  >
248
264
  {detail}
@@ -253,7 +269,14 @@ export const Card = forwardRef<HTMLDivElement, CardProps>(function Card(
253
269
 
254
270
  export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
255
271
  function CardHeader({ className, ...props }, ref) {
256
- return <div ref={ref} className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />;
272
+ return (
273
+ <div
274
+ ref={ref}
275
+ data-slot="card-header"
276
+ className={cn("flex flex-col gap-1.5 p-6", className)}
277
+ {...props}
278
+ />
279
+ );
257
280
  },
258
281
  );
259
282
 
@@ -276,7 +299,14 @@ export const CardTitle = forwardRef<HTMLDivElement, CardTitleProps>(function Car
276
299
  { className, as: Tag = "div", ...props },
277
300
  ref,
278
301
  ) {
279
- return <Tag ref={ref} className={cn("text-title leading-none", className)} {...props} />;
302
+ return (
303
+ <Tag
304
+ ref={ref}
305
+ data-slot="card-title"
306
+ className={cn("text-title leading-none", className)}
307
+ {...props}
308
+ />
309
+ );
280
310
  });
281
311
 
282
312
  export interface CardDescriptionProps extends HTMLAttributes<HTMLParagraphElement> {
@@ -293,11 +323,12 @@ export const CardDescription = forwardRef<HTMLParagraphElement, CardDescriptionP
293
323
  return (
294
324
  <p
295
325
  ref={ref}
326
+ data-slot="card-description"
296
327
  // `text-balance` sets `text-wrap: balance`. #336's real defect was an
297
328
  // invalid, misspelled utility name that emitted zero CSS (NOT a
298
329
  // tailwind-merge conflict) — this is the real, registered class.
299
330
  className={cn(
300
- "text-sm text-balance text-muted-foreground",
331
+ "text-body text-balance text-muted-foreground",
301
332
  measure && "max-w-prose",
302
333
  className,
303
334
  )}
@@ -312,6 +343,7 @@ export const CardAction = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivEleme
312
343
  return (
313
344
  <div
314
345
  ref={ref}
346
+ data-slot="card-action"
315
347
  className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
316
348
  {...props}
317
349
  />
@@ -321,12 +353,21 @@ export const CardAction = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivEleme
321
353
 
322
354
  export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
323
355
  function CardContent({ className, ...props }, ref) {
324
- return <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />;
356
+ return (
357
+ <div ref={ref} data-slot="card-content" className={cn("p-6 pt-0", className)} {...props} />
358
+ );
325
359
  },
326
360
  );
327
361
 
328
362
  export const CardFooter = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
329
363
  function CardFooter({ className, ...props }, ref) {
330
- return <div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />;
364
+ return (
365
+ <div
366
+ ref={ref}
367
+ data-slot="card-footer"
368
+ className={cn("flex items-center p-6 pt-0", className)}
369
+ {...props}
370
+ />
371
+ );
331
372
  },
332
373
  );
@@ -0,0 +1,169 @@
1
+ import { createRef } from "react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { fireEvent, render, screen } from "@testing-library/react";
4
+ import { DirectionProvider } from "@radix-ui/react-direction";
5
+ import {
6
+ Carousel,
7
+ CarouselContent,
8
+ CarouselItem,
9
+ CarouselNext,
10
+ CarouselPrevious,
11
+ } from "./carousel";
12
+
13
+ function renderCarousel(props: Partial<React.ComponentProps<typeof Carousel>> = {}) {
14
+ return render(
15
+ <Carousel {...props}>
16
+ <CarouselContent>
17
+ <CarouselItem>
18
+ <input aria-label="slide input" defaultValue="abc" />
19
+ </CarouselItem>
20
+ <CarouselItem>Slide 2</CarouselItem>
21
+ </CarouselContent>
22
+ <CarouselPrevious />
23
+ <CarouselNext />
24
+ </Carousel>,
25
+ );
26
+ }
27
+
28
+ describe("Carousel keyboard handling", () => {
29
+ it("does not intercept ArrowLeft/ArrowRight when the target is a text input", () => {
30
+ renderCarousel();
31
+ const input = screen.getByLabelText("slide input") as HTMLInputElement;
32
+ input.focus();
33
+ const event = fireEvent.keyDown(input, { key: "ArrowRight", bubbles: true });
34
+ // `fireEvent` returns `false` from `dispatchEvent` only when
35
+ // `preventDefault()` was called — the carousel must leave the caret event
36
+ // alone so it reaches the input's own default behavior.
37
+ expect(event).toBe(true);
38
+ });
39
+
40
+ it("still intercepts ArrowLeft/ArrowRight outside of an editable target", () => {
41
+ renderCarousel();
42
+ const region = screen.getByRole("region");
43
+ const event = fireEvent.keyDown(region, { key: "ArrowRight", bubbles: true });
44
+ expect(event).toBe(false);
45
+ });
46
+ });
47
+
48
+ describe("Carousel RTL keyboard mapping", () => {
49
+ it("flips ArrowLeft/ArrowRight to the visually-adjacent slide under dir=rtl", () => {
50
+ let scrollPrevSpy: ReturnType<typeof vi.spyOn> | undefined;
51
+ let scrollNextSpy: ReturnType<typeof vi.spyOn> | undefined;
52
+ render(
53
+ <DirectionProvider dir="rtl">
54
+ <Carousel
55
+ setApi={(api) => {
56
+ if (!api) return;
57
+ scrollPrevSpy = vi.spyOn(api, "scrollPrev");
58
+ scrollNextSpy = vi.spyOn(api, "scrollNext");
59
+ }}
60
+ >
61
+ <CarouselContent>
62
+ <CarouselItem>Slide 1</CarouselItem>
63
+ <CarouselItem>Slide 2</CarouselItem>
64
+ </CarouselContent>
65
+ </Carousel>
66
+ </DirectionProvider>,
67
+ );
68
+ const region = screen.getByRole("region");
69
+ // In RTL, ArrowLeft is the visually-forward direction — it must trigger
70
+ // the SAME embla call the "Next slide" button does, not "Previous".
71
+ fireEvent.keyDown(region, { key: "ArrowLeft" });
72
+ expect(scrollNextSpy).toHaveBeenCalledTimes(1);
73
+ expect(scrollPrevSpy).not.toHaveBeenCalled();
74
+
75
+ fireEvent.keyDown(region, { key: "ArrowRight" });
76
+ expect(scrollPrevSpy).toHaveBeenCalledTimes(1);
77
+ });
78
+
79
+ it("keeps the physical mapping under dir=ltr (default)", () => {
80
+ let scrollPrevSpy: ReturnType<typeof vi.spyOn> | undefined;
81
+ let scrollNextSpy: ReturnType<typeof vi.spyOn> | undefined;
82
+ render(
83
+ <Carousel
84
+ setApi={(api) => {
85
+ if (!api) return;
86
+ scrollPrevSpy = vi.spyOn(api, "scrollPrev");
87
+ scrollNextSpy = vi.spyOn(api, "scrollNext");
88
+ }}
89
+ >
90
+ <CarouselContent>
91
+ <CarouselItem>Slide 1</CarouselItem>
92
+ <CarouselItem>Slide 2</CarouselItem>
93
+ </CarouselContent>
94
+ </Carousel>,
95
+ );
96
+ const region = screen.getByRole("region");
97
+ fireEvent.keyDown(region, { key: "ArrowRight" });
98
+ expect(scrollNextSpy).toHaveBeenCalledTimes(1);
99
+ fireEvent.keyDown(region, { key: "ArrowLeft" });
100
+ expect(scrollPrevSpy).toHaveBeenCalledTimes(1);
101
+ });
102
+
103
+ it("uses ArrowUp/ArrowDown instead of ArrowLeft/ArrowRight for a vertical carousel", () => {
104
+ let scrollPrevSpy: ReturnType<typeof vi.spyOn> | undefined;
105
+ let scrollNextSpy: ReturnType<typeof vi.spyOn> | undefined;
106
+ render(
107
+ <Carousel
108
+ orientation="vertical"
109
+ setApi={(api) => {
110
+ if (!api) return;
111
+ scrollPrevSpy = vi.spyOn(api, "scrollPrev");
112
+ scrollNextSpy = vi.spyOn(api, "scrollNext");
113
+ }}
114
+ >
115
+ <CarouselContent>
116
+ <CarouselItem>Slide 1</CarouselItem>
117
+ <CarouselItem>Slide 2</CarouselItem>
118
+ </CarouselContent>
119
+ </Carousel>,
120
+ );
121
+ const region = screen.getByRole("region");
122
+ fireEvent.keyDown(region, { key: "ArrowRight" });
123
+ expect(scrollNextSpy).not.toHaveBeenCalled();
124
+ fireEvent.keyDown(region, { key: "ArrowDown" });
125
+ expect(scrollNextSpy).toHaveBeenCalledTimes(1);
126
+ fireEvent.keyDown(region, { key: "ArrowUp" });
127
+ expect(scrollPrevSpy).toHaveBeenCalledTimes(1);
128
+ });
129
+ });
130
+
131
+ describe("CarouselPrevious / CarouselNext", () => {
132
+ it("forward a ref to the underlying button", () => {
133
+ const prevRef = createRef<HTMLButtonElement>();
134
+ const nextRef = createRef<HTMLButtonElement>();
135
+ renderCarousel();
136
+ render(
137
+ <Carousel>
138
+ <CarouselContent>
139
+ <CarouselItem>1</CarouselItem>
140
+ </CarouselContent>
141
+ <CarouselPrevious ref={prevRef} />
142
+ <CarouselNext ref={nextRef} />
143
+ </Carousel>,
144
+ );
145
+ expect(prevRef.current).toBeInstanceOf(HTMLButtonElement);
146
+ expect(nextRef.current).toBeInstanceOf(HTMLButtonElement);
147
+ });
148
+
149
+ it("keeps the buttons inset on narrow viewports (no unconditional negative offset)", () => {
150
+ render(
151
+ <Carousel>
152
+ <CarouselContent>
153
+ <CarouselItem>1</CarouselItem>
154
+ </CarouselContent>
155
+ <CarouselPrevious />
156
+ <CarouselNext />
157
+ </Carousel>,
158
+ );
159
+ const prev = screen.getByRole("button", { name: "Previous slide" });
160
+ const next = screen.getByRole("button", { name: "Next slide" });
161
+ // The negative offset that pushes the button outside the carousel box
162
+ // only applies from `sm:` up; unconditionally offsetting it caused
163
+ // horizontal overflow on narrow phone screens.
164
+ expect(prev.className).not.toMatch(/(?<!sm:)-start-12/);
165
+ expect(next.className).not.toMatch(/(?<!sm:)-end-12/);
166
+ expect(prev.className).toContain("sm:-start-12");
167
+ expect(next.className).toContain("sm:-end-12");
168
+ });
169
+ });