@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
@@ -1,7 +1,10 @@
1
+ "use client";
2
+
1
3
  import { forwardRef, useState, type ComponentPropsWithoutRef } from "react";
2
4
  import { defaultFilter } from "cmdk";
3
5
  import { Check, ChevronsUpDown, Plus } from "lucide-react";
4
6
  import { cn } from "../../lib/cn";
7
+ import { useControllableState } from "../../lib/use-controllable-state";
5
8
  import { Button } from "../button";
6
9
  import { Popover, PopoverContent, PopoverTrigger } from "../popover";
7
10
  import { useLocale } from "../locale-provider";
@@ -20,6 +23,12 @@ export interface ComboboxOption {
20
23
  }
21
24
 
22
25
  export interface ComboboxProps {
26
+ /**
27
+ * The option list — every option is mounted in the DOM (cmdk has no built-in
28
+ * windowing), so this is fine for typical form/menu-sized lists but not for
29
+ * open-ended data. For thousands of options, use `VirtualSelect` instead,
30
+ * which windows rendering via `@tanstack/react-virtual`.
31
+ */
23
32
  options: ComboboxOption[];
24
33
  value?: string;
25
34
  onValueChange?: (value: string) => void;
@@ -97,8 +106,8 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
97
106
  options,
98
107
  value,
99
108
  onValueChange,
100
- placeholder = "Select…",
101
- searchPlaceholder = "Search…",
109
+ placeholder,
110
+ searchPlaceholder,
102
111
  emptyText,
103
112
  className,
104
113
  disabled = false,
@@ -111,11 +120,11 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
111
120
  ref,
112
121
  ) {
113
122
  const { t } = useLocale();
123
+ const resolvedPlaceholder = placeholder ?? t("ui.combobox.placeholder");
124
+ const resolvedSearchPlaceholder = searchPlaceholder ?? t("ui.combobox.searchPlaceholder");
114
125
  const [open, setOpen] = useState(false);
115
- const [internal, setInternal] = useState("");
126
+ const [selected, setValue] = useControllableState(value, "", onValueChange);
116
127
  const [search, setSearch] = useState("");
117
- const selected = value ?? internal;
118
- const setValue = (v: string) => (onValueChange ? onValueChange(v) : setInternal(v));
119
128
  const current = options.find((o) => o.value === selected);
120
129
 
121
130
  const trimmedSearch = search.trim();
@@ -150,30 +159,35 @@ export const Combobox = forwardRef<HTMLButtonElement, ComboboxProps>(function Co
150
159
  aria-expanded={open}
151
160
  disabled={disabled}
152
161
  data-slot="combobox"
153
- className={cn("w-56 justify-between font-normal", triggerProps?.className, className)}
162
+ className={cn("w-full justify-between font-normal", triggerProps?.className, className)}
154
163
  >
155
- {current ? current.label : selected || placeholder}
164
+ {current ? current.label : selected || resolvedPlaceholder}
156
165
  <ChevronsUpDown className="ms-2 size-4 shrink-0 opacity-50" />
157
166
  </Button>
158
167
  </PopoverTrigger>
159
168
  <PopoverContent
160
- className="w-56 p-0"
169
+ className="w-[var(--radix-popover-trigger-width)] min-w-56 p-0"
161
170
  data-slot="combobox-content"
162
171
  // Radix Popover.Content renders role="dialog"; without a name axe's
163
172
  // aria-dialog-name rule (rightly) flags it. Reuse the trigger's own
164
173
  // purpose label when the consumer set one, else fall back to the
165
174
  // (already-translatable) search placeholder — no new hardcoded copy.
166
- aria-label={ariaLabel ?? searchPlaceholder}
175
+ aria-label={ariaLabel ?? resolvedSearchPlaceholder}
167
176
  >
168
177
  <Command filter={allowCustomValue ? customValueFilter : undefined}>
169
- <CommandInput placeholder={searchPlaceholder} value={search} onValueChange={setSearch} />
178
+ <CommandInput
179
+ placeholder={resolvedSearchPlaceholder}
180
+ value={search}
181
+ onValueChange={setSearch}
182
+ />
170
183
  <CommandList>
171
184
  <CommandEmpty>{resolvedEmptyText}</CommandEmpty>
172
185
  <CommandGroup>
173
186
  {options.map((o) => (
174
187
  <CommandItem
175
188
  key={o.value}
176
- value={o.label}
189
+ value={o.value}
190
+ keywords={[o.label]}
177
191
  onSelect={() => {
178
192
  setValue(o.value === selected ? "" : o.value);
179
193
  setOpen(false);
@@ -5,6 +5,7 @@ import userEvent from "@testing-library/user-event";
5
5
  import {
6
6
  Command,
7
7
  CommandDialog,
8
+ CommandEmpty,
8
9
  CommandGroup,
9
10
  CommandInput,
10
11
  CommandItem,
@@ -351,3 +352,17 @@ describe("Command — CommandSeparator a11y (#157)", () => {
351
352
  expect(screen.getByRole("listbox")).toBeInTheDocument();
352
353
  });
353
354
  });
355
+
356
+ describe("CommandEmpty — merges className instead of replacing it", () => {
357
+ it("keeps the base py-6/text-center classes alongside a caller className", () => {
358
+ render(
359
+ <Command>
360
+ <CommandList>
361
+ <CommandEmpty className="custom-empty">No results</CommandEmpty>
362
+ </CommandList>
363
+ </Command>,
364
+ );
365
+ const empty = screen.getByText("No results");
366
+ expect(empty).toHaveClass("py-6", "text-center", "custom-empty");
367
+ });
368
+ });
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import {
2
4
  createContext,
3
5
  forwardRef,
@@ -199,7 +201,7 @@ export const CommandInput = forwardRef<
199
201
  ref={ref}
200
202
  data-slot="command-input"
201
203
  className={cn(
202
- "flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
204
+ "flex h-10 w-full rounded-md bg-transparent py-3 text-body outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
203
205
  className,
204
206
  )}
205
207
  {...props}
@@ -225,12 +227,12 @@ export const CommandList = forwardRef<
225
227
  export const CommandEmpty = forwardRef<
226
228
  ElementRef<typeof CommandPrimitive.Empty>,
227
229
  ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
228
- >(function CommandEmpty(props, ref) {
230
+ >(function CommandEmpty({ className, ...props }, ref) {
229
231
  return (
230
232
  <CommandPrimitive.Empty
231
233
  ref={ref}
232
234
  data-slot="command-empty"
233
- className="py-6 text-center text-sm"
235
+ className={cn("py-6 text-center text-body", className)}
234
236
  {...props}
235
237
  />
236
238
  );
@@ -245,7 +247,7 @@ export const CommandGroup = forwardRef<
245
247
  ref={ref}
246
248
  data-slot="command-group"
247
249
  className={cn(
248
- "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
250
+ "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-meta [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
249
251
  className,
250
252
  )}
251
253
  {...props}
@@ -305,7 +307,7 @@ export const CommandItem = forwardRef<
305
307
  ref={ref}
306
308
  data-slot="command-item"
307
309
  className={cn(
308
- "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors duration-fast data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4",
310
+ "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-body outline-none transition-colors duration-fast data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:size-4",
309
311
  className,
310
312
  )}
311
313
  {...props}
@@ -317,7 +319,7 @@ export function CommandShortcut({ className, ...props }: HTMLAttributes<HTMLSpan
317
319
  return (
318
320
  <span
319
321
  data-slot="command-shortcut"
320
- className={cn("ms-auto text-xs tracking-widest text-muted-foreground", className)}
322
+ className={cn("ms-auto text-meta tracking-widest text-muted-foreground", className)}
321
323
  {...props}
322
324
  />
323
325
  );
@@ -21,8 +21,9 @@ export const ContextMenuSubTrigger = forwardRef<
21
21
  return (
22
22
  <ContextMenuPrimitive.SubTrigger
23
23
  ref={ref}
24
+ data-slot="context-menu-sub-trigger"
24
25
  className={cn(
25
- "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors duration-fast focus:bg-accent data-[state=open]:bg-accent",
26
+ "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-body outline-none transition-colors duration-fast focus:bg-accent data-[state=open]:bg-accent",
26
27
  inset && "ps-8",
27
28
  className,
28
29
  )}
@@ -41,6 +42,7 @@ export const ContextMenuSubContent = forwardRef<
41
42
  return (
42
43
  <ContextMenuPrimitive.SubContent
43
44
  ref={ref}
45
+ data-slot="context-menu-sub-content"
44
46
  className={cn(
45
47
  "z-50 min-w-[8rem] overflow-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-ring-md 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]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2",
46
48
  className,
@@ -58,8 +60,9 @@ export const ContextMenuContent = forwardRef<
58
60
  <ContextMenuPrimitive.Portal>
59
61
  <ContextMenuPrimitive.Content
60
62
  ref={ref}
63
+ data-slot="context-menu-content"
61
64
  className={cn(
62
- "z-50 min-w-[8rem] overflow-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-ring-md 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]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2",
65
+ "z-50 min-w-[12rem] overflow-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-ring-md 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]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2",
63
66
  className,
64
67
  )}
65
68
  {...props}
@@ -75,8 +78,9 @@ export const ContextMenuItem = forwardRef<
75
78
  return (
76
79
  <ContextMenuPrimitive.Item
77
80
  ref={ref}
81
+ data-slot="context-menu-item"
78
82
  className={cn(
79
- "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors duration-fast focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4",
83
+ "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-body outline-none transition-colors duration-fast focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:size-4",
80
84
  inset && "ps-8",
81
85
  className,
82
86
  )}
@@ -92,9 +96,10 @@ export const ContextMenuCheckboxItem = forwardRef<
92
96
  return (
93
97
  <ContextMenuPrimitive.CheckboxItem
94
98
  ref={ref}
99
+ data-slot="context-menu-checkbox-item"
95
100
  checked={checked}
96
101
  className={cn(
97
- "relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none transition-colors duration-fast focus:bg-accent",
102
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-body outline-none transition-colors duration-fast focus:bg-accent",
98
103
  className,
99
104
  )}
100
105
  {...props}
@@ -116,8 +121,9 @@ export const ContextMenuRadioItem = forwardRef<
116
121
  return (
117
122
  <ContextMenuPrimitive.RadioItem
118
123
  ref={ref}
124
+ data-slot="context-menu-radio-item"
119
125
  className={cn(
120
- "relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none transition-colors duration-fast focus:bg-accent",
126
+ "relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-body outline-none transition-colors duration-fast focus:bg-accent",
121
127
  className,
122
128
  )}
123
129
  {...props}
@@ -139,8 +145,9 @@ export const ContextMenuLabel = forwardRef<
139
145
  return (
140
146
  <ContextMenuPrimitive.Label
141
147
  ref={ref}
148
+ data-slot="context-menu-label"
142
149
  className={cn(
143
- "px-2 py-1.5 text-xs font-semibold text-muted-foreground",
150
+ "px-2 py-1.5 text-meta font-semibold text-muted-foreground",
144
151
  inset && "ps-8",
145
152
  className,
146
153
  )}
@@ -156,6 +163,7 @@ export const ContextMenuSeparator = forwardRef<
156
163
  return (
157
164
  <ContextMenuPrimitive.Separator
158
165
  ref={ref}
166
+ data-slot="context-menu-separator"
159
167
  className={cn("-mx-1 my-1 h-px bg-border", className)}
160
168
  {...props}
161
169
  />
@@ -165,7 +173,8 @@ export const ContextMenuSeparator = forwardRef<
165
173
  export function ContextMenuShortcut({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
166
174
  return (
167
175
  <span
168
- className={cn("ms-auto text-xs tracking-widest text-muted-foreground", className)}
176
+ data-slot="context-menu-shortcut"
177
+ className={cn("ms-auto text-meta tracking-widest text-muted-foreground", className)}
169
178
  {...props}
170
179
  />
171
180
  );
@@ -162,7 +162,7 @@ const COUNT_BADGE_POSITION = "end-0 -top-1 h-4 min-w-4 px-0.5";
162
162
  // The rail's own header band. Two jobs, and both are geometry the rail cannot
163
163
  // leave to its children:
164
164
  //
165
- // 1. EXPANDED it is `h-14` — the same 56px an app shell's top bar carries
165
+ // 1. EXPANDED it is `h-header` (56px by default) — the same height an app shell's top bar carries
166
166
  // (`AppShell`, and both shipped shell blocks) — and it holds the section
167
167
  // heading AND the switcher in ONE row. Before this, the switcher row (32px,
168
168
  // flush at y=0) and a separately-bordered heading (53px) stacked to 85px, so
@@ -177,7 +177,7 @@ const COUNT_BADGE_POSITION = "end-0 -top-1 h-4 min-w-4 px-0.5";
177
177
  // centres the FIRST icon in the same 56px band as the top bar's controls, so
178
178
  // the two rails' first rows agree.
179
179
  const CONTEXT_RAIL_HEADER =
180
- "flex h-14 shrink-0 items-center gap-2 border-b border-border-strong px-2 group-data-[collapsible=icon]:h-auto group-data-[collapsible=icon]:flex-col group-data-[collapsible=icon]:items-stretch group-data-[collapsible=icon]:border-b-0 group-data-[collapsible=icon]:px-2 group-data-[collapsible=icon]:pt-3 group-data-[collapsible=icon]:pb-2";
180
+ "flex h-header shrink-0 items-center gap-2 border-b border-border-strong px-2 group-data-[collapsible=icon]:h-auto group-data-[collapsible=icon]:flex-col group-data-[collapsible=icon]:items-stretch group-data-[collapsible=icon]:border-b-0 group-data-[collapsible=icon]:px-2 group-data-[collapsible=icon]:pt-3 group-data-[collapsible=icon]:pb-2";
181
181
 
182
182
  function ContextRailSwitcher({
183
183
  sections,
@@ -491,10 +491,10 @@ const ContextRailNarrow = forwardRef<HTMLDivElement, ContextRailNarrowProps>(
491
491
  above; this is the visible heading the body sits under. */}
492
492
  <h2
493
493
  data-slot="context-rail-heading"
494
- // `h-14`, matching the wide branch's header band and the app
494
+ // `h-header`, matching the wide branch's header band and the app
495
495
  // shell's top bar — the sheet is the same rail at a narrower
496
496
  // width, so its header keeps the same height.
497
- className="flex h-14 shrink-0 items-center truncate border-b border-border-strong px-4 text-title"
497
+ className="flex h-header shrink-0 items-center truncate border-b border-border-strong px-4 text-title"
498
498
  >
499
499
  {activeSection.label}
500
500
  </h2>
@@ -1,11 +1,21 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
2
  import { expect, userEvent, within } from "storybook/test";
3
+ import { Toaster } from "../sonner";
3
4
  import { CopyableValue } from "./copyable-value";
4
5
 
5
6
  const meta = {
6
7
  title: "Display/CopyableValue",
7
8
  component: CopyableValue,
8
9
  tags: ["autodocs"],
10
+ // The copy toast needs a host; apps mount one `<Toaster />` at their root.
11
+ decorators: [
12
+ (Story) => (
13
+ <>
14
+ <Story />
15
+ <Toaster />
16
+ </>
17
+ ),
18
+ ],
9
19
  parameters: {
10
20
  docs: {
11
21
  description: {
@@ -11,7 +11,10 @@
11
11
  import { describe, expect, it, vi } from "vitest";
12
12
  import { render, screen, fireEvent, waitFor } from "@testing-library/react";
13
13
  import userEvent from "@testing-library/user-event";
14
- import { CopyableValue } from "./copyable-value";
14
+ import { toast } from "../sonner";
15
+ import { CopyableValue, COPYABLE_VALUE_TOAST_MS } from "./copyable-value";
16
+
17
+ vi.mock("../sonner", () => ({ toast: vi.fn() }));
15
18
 
16
19
  /** Install a clipboard stub for one test; returns the writeText spy. */
17
20
  function stubClipboard(impl: () => Promise<void> = () => Promise.resolve()) {
@@ -38,6 +41,30 @@ describe("CopyableValue", () => {
38
41
  });
39
42
  });
40
43
 
44
+ it("shows a one-second toast once the copy succeeds", async () => {
45
+ vi.mocked(toast).mockClear();
46
+ stubClipboard();
47
+ render(<CopyableValue value="42">42</CopyableValue>);
48
+
49
+ fireEvent.click(screen.getByRole("button"));
50
+ await waitFor(() => {
51
+ expect(toast).toHaveBeenCalledWith("Copied", { duration: COPYABLE_VALUE_TOAST_MS });
52
+ });
53
+ expect(COPYABLE_VALUE_TOAST_MS).toBe(1000);
54
+ });
55
+
56
+ it("shows no toast when the copy fails", async () => {
57
+ vi.mocked(toast).mockClear();
58
+ stubClipboard(() => Promise.reject(new Error("denied")));
59
+ render(<CopyableValue value="42">42</CopyableValue>);
60
+
61
+ fireEvent.click(screen.getByRole("button"));
62
+ await waitFor(() => {
63
+ expect(screen.getByRole("status")).toHaveTextContent("");
64
+ });
65
+ expect(toast).not.toHaveBeenCalled();
66
+ });
67
+
41
68
  it("falls back to the exact value as its own display", () => {
42
69
  render(<CopyableValue value="1234" />);
43
70
  expect(screen.getByRole("button")).toHaveTextContent("1234");
@@ -19,6 +19,10 @@ import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
19
19
  import { cn } from "../../lib/cn";
20
20
  import { useCopyToClipboard } from "../../lib/use-copy-to-clipboard";
21
21
  import { useLocale } from "../locale-provider";
22
+ import { toast } from "../sonner";
23
+
24
+ /** ms the copy-confirmation toast stays on screen. */
25
+ export const COPYABLE_VALUE_TOAST_MS = 1000;
22
26
 
23
27
  export interface CopyableValueProps extends Omit<
24
28
  ButtonHTMLAttributes<HTMLButtonElement>,
@@ -79,7 +83,14 @@ export const CopyableValue = forwardRef<HTMLButtonElement, CopyableValueProps>(
79
83
  onClick={(event) => {
80
84
  onClick?.(event);
81
85
  if (!event.defaultPrevented) {
82
- void copy(value);
86
+ // The toast is the visible confirmation; it needs an app-mounted
87
+ // `<Toaster />` and is a silent no-op without one, which is why
88
+ // the live region below stays as the always-present fallback.
89
+ void copy(value).then((ok) => {
90
+ if (ok) {
91
+ toast(t("ui.copyableValue.copied"), { duration: COPYABLE_VALUE_TOAST_MS });
92
+ }
93
+ });
83
94
  }
84
95
  }}
85
96
  >
@@ -5,6 +5,15 @@ const meta = {
5
5
  title: "Forms/DatePicker",
6
6
  component: DatePicker,
7
7
  tags: ["autodocs"],
8
+ // The trigger fills its container by default (no hard-fixed width) — cap
9
+ // the docs canvas to a realistic form-field width.
10
+ decorators: [
11
+ (Story) => (
12
+ <div className="w-full max-w-xs">
13
+ <Story />
14
+ </div>
15
+ ),
16
+ ],
8
17
  argTypes: {
9
18
  value: {
10
19
  description: "Controlled selected date.",
@@ -0,0 +1,52 @@
1
+ import { createRef } from "react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { render, screen, within } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+ import { DatePicker } from "./date-picker";
6
+ import { LocaleProvider } from "../locale-provider";
7
+
8
+ describe("DatePicker", () => {
9
+ it("forwards the ref to the trigger button", () => {
10
+ const ref = createRef<HTMLButtonElement>();
11
+ render(<DatePicker ref={ref} />);
12
+ expect(ref.current).toBeInstanceOf(HTMLButtonElement);
13
+ expect(ref.current).toBe(screen.getByRole("button"));
14
+ });
15
+
16
+ it("does not hard-fix the trigger width — fills its container by default", () => {
17
+ render(<DatePicker />);
18
+ expect(screen.getByRole("button").className).not.toMatch(/\bw-56\b/);
19
+ });
20
+
21
+ // #reviewed 1.7 — same `value ?? internal` / ternary-setter bug as Combobox:
22
+ // an uncontrolled DatePicker with an onValueChange listener never updated
23
+ // its own displayed date.
24
+ it("updates its own displayed date when uncontrolled even with an onValueChange listener", async () => {
25
+ const onValueChange = vi.fn();
26
+ const user = userEvent.setup();
27
+ render(<DatePicker onValueChange={onValueChange} placeholder="Pick a date" />);
28
+ const trigger = screen.getByRole("button", { name: /pick a date/i });
29
+ await user.click(trigger);
30
+ const grid = await screen.findByRole("grid");
31
+ const dayButtons = within(grid)
32
+ .getAllByRole("button")
33
+ .filter((b) => !/previous|next/i.test(b.getAttribute("aria-label") ?? ""));
34
+ const firstEnabled = dayButtons.find((b) => !(b as HTMLButtonElement).disabled)!;
35
+ await user.click(firstEnabled);
36
+ expect(onValueChange).toHaveBeenCalled();
37
+ expect(trigger).not.toHaveTextContent("Pick a date");
38
+ });
39
+
40
+ it("formats the displayed date using the active locale", () => {
41
+ const date = new Date(2024, 0, 15); // Jan 15, 2024
42
+ render(
43
+ <LocaleProvider locale="de-DE">
44
+ <DatePicker value={date} />
45
+ </LocaleProvider>,
46
+ );
47
+ // de-DE "medium" dateStyle renders like "15. Jan. 2024" — distinct from
48
+ // the en-US default ("Jan 15, 2024").
49
+ expect(screen.getByRole("button")).toHaveTextContent("2024");
50
+ expect(screen.getByRole("button").textContent).not.toMatch(/Jan 15, 2024/);
51
+ });
52
+ });
@@ -1,8 +1,10 @@
1
- import { useState } from "react";
1
+ import { forwardRef } from "react";
2
2
  import { Calendar as CalendarIcon } from "lucide-react";
3
3
  import { cn } from "../../lib/cn";
4
+ import { useControllableState } from "../../lib/use-controllable-state";
4
5
  import { Button } from "../button";
5
6
  import { Calendar } from "../calendar";
7
+ import { useLocale } from "../locale-provider";
6
8
  import { Popover, PopoverContent, PopoverTrigger } from "../popover";
7
9
 
8
10
  export interface DatePickerProps {
@@ -13,26 +15,22 @@ export interface DatePickerProps {
13
15
  }
14
16
 
15
17
  /** Single-date picker (Popover + Calendar). Controlled or uncontrolled. */
16
- export function DatePicker({
17
- value,
18
- onValueChange,
19
- placeholder = "Pick a date",
20
- className,
21
- }: DatePickerProps) {
22
- const [internal, setInternal] = useState<Date | undefined>(undefined);
23
- const date = value ?? internal;
24
- const setDate = (d?: Date) => (onValueChange ? onValueChange(d) : setInternal(d));
25
- const label = date
26
- ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(date)
27
- : placeholder;
18
+ export const DatePicker = forwardRef<HTMLButtonElement, DatePickerProps>(function DatePicker(
19
+ { value, onValueChange, placeholder = "Pick a date", className },
20
+ ref,
21
+ ) {
22
+ const { formatDate } = useLocale();
23
+ const [date, setDate] = useControllableState<Date | undefined>(value, undefined, onValueChange);
24
+ const label = date ? formatDate(date, { dateStyle: "medium" }) : placeholder;
28
25
 
29
26
  return (
30
27
  <Popover>
31
28
  <PopoverTrigger asChild>
32
29
  <Button
30
+ ref={ref}
33
31
  variant="outline"
34
32
  className={cn(
35
- "w-56 justify-start gap-2 text-start font-normal",
33
+ "w-full justify-start gap-2 text-start font-normal",
36
34
  !date && "text-muted-foreground",
37
35
  className,
38
36
  )}
@@ -46,4 +44,4 @@ export function DatePicker({
46
44
  </PopoverContent>
47
45
  </Popover>
48
46
  );
49
- }
47
+ });
@@ -12,6 +12,15 @@ const meta = {
12
12
  parameters: {
13
13
  layout: "centered",
14
14
  },
15
+ // The trigger fills its container by default (no hard-fixed width) — cap
16
+ // the docs canvas to a realistic form-field width.
17
+ decorators: [
18
+ (Story) => (
19
+ <div className="w-80">
20
+ <Story />
21
+ </div>
22
+ ),
23
+ ],
15
24
  argTypes: {
16
25
  value: {
17
26
  description: "Controlled date range `{ from, to }`.",
@@ -6,9 +6,12 @@
6
6
  * select interaction and accessibility are covered by the Storybook stories
7
7
  * (run via `pnpm --filter @elabs-ai/components-docs test-storybook` or the MCP story tests).
8
8
  */
9
+ import { useState } from "react";
9
10
  import { describe, expect, it } from "vitest";
10
11
  import { render, screen } from "@testing-library/react";
11
- import { DateRangePicker } from "./date-range-picker";
12
+ import userEvent from "@testing-library/user-event";
13
+ import { DateRangePicker, type DateRange } from "./date-range-picker";
14
+ import { LocaleProvider } from "../locale-provider";
12
15
 
13
16
  describe("DateRangePicker", () => {
14
17
  it("renders the placeholder on the trigger when no range is set", () => {
@@ -59,4 +62,76 @@ describe("DateRangePicker", () => {
59
62
  const button = screen.getByRole("button");
60
63
  expect(button.className).toContain("my-custom-class");
61
64
  });
65
+
66
+ it("does not hard-fix the trigger width — fills its container by default", () => {
67
+ render(<DateRangePicker />);
68
+ expect(screen.getByRole("button").className).not.toMatch(/\bw-72\b/);
69
+ });
70
+
71
+ it("spreads extra props (e.g. data-testid) onto the trigger button", () => {
72
+ render(<DateRangePicker data-testid="range-trigger" />);
73
+ expect(screen.getByTestId("range-trigger")).toBeInTheDocument();
74
+ });
75
+
76
+ it("formats the range label using the active locale", () => {
77
+ const from = new Date(2024, 0, 5);
78
+ const to = new Date(2024, 0, 20);
79
+ render(
80
+ <LocaleProvider locale="de-DE">
81
+ <DateRangePicker value={{ from, to }} />
82
+ </LocaleProvider>,
83
+ );
84
+ // de-DE renders "5. Jan. 2024" style — distinct from the en-US default.
85
+ expect(screen.getByRole("button").textContent).not.toMatch(/Jan 5, 2024/);
86
+ });
87
+
88
+ // #reviewed 1.7 — clearing a controlled range to `undefined` must not fall
89
+ // through to a stale uncontrolled `internal` value: once controlled, the
90
+ // component must stay controlled for its whole lifetime.
91
+ it("collapses to a single month below the sm breakpoint (phone-width popover)", async () => {
92
+ const originalWidth = window.innerWidth;
93
+ Object.defineProperty(window, "innerWidth", {
94
+ writable: true,
95
+ configurable: true,
96
+ value: 375,
97
+ });
98
+ try {
99
+ const user = userEvent.setup();
100
+ render(<DateRangePicker />);
101
+ await user.click(screen.getByRole("button"));
102
+ const grids = await screen.findAllByRole("grid");
103
+ expect(grids).toHaveLength(1);
104
+ } finally {
105
+ Object.defineProperty(window, "innerWidth", {
106
+ writable: true,
107
+ configurable: true,
108
+ value: originalWidth,
109
+ });
110
+ }
111
+ });
112
+
113
+ it("shows the placeholder (not a stale value) when a controlled range is cleared to undefined", async () => {
114
+ function Harness() {
115
+ const [range, setRange] = useState<DateRange | undefined>({
116
+ from: new Date(2024, 0, 5),
117
+ to: new Date(2024, 0, 20),
118
+ });
119
+ return (
120
+ <>
121
+ <DateRangePicker value={range} onValueChange={setRange} placeholder="Pick a range" />
122
+ <button type="button" onClick={() => setRange(undefined)}>
123
+ Clear
124
+ </button>
125
+ </>
126
+ );
127
+ }
128
+ const user = userEvent.setup();
129
+ render(<Harness />);
130
+ const trigger = screen.getByRole("button", { name: /–/ });
131
+ expect(trigger.textContent).not.toBe("Pick a range");
132
+
133
+ await user.click(screen.getByRole("button", { name: "Clear" }));
134
+
135
+ expect(trigger).toHaveTextContent("Pick a range");
136
+ });
62
137
  });