@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,14 +1,20 @@
1
1
  // a2ui.exposed: yes
2
2
  "use client";
3
3
 
4
- import { forwardRef, useState } from "react";
4
+ import { forwardRef, type HTMLAttributes } from "react";
5
5
  import { Calendar as CalendarIcon } from "lucide-react";
6
6
  import type { DateRange } from "react-day-picker";
7
7
  import { cn } from "../../lib/cn";
8
+ import { useControllableState } from "../../lib/use-controllable-state";
9
+ import { useIsMobile } from "../../lib/use-mobile";
8
10
  import { Button } from "../button";
9
11
  import { Calendar } from "../calendar";
12
+ import { useLocale } from "../locale-provider";
10
13
  import { Popover, PopoverContent, PopoverTrigger } from "../popover";
11
14
 
15
+ /** Below this width, the calendar collapses to a single month (Tailwind `sm`). */
16
+ const NARROW_BREAKPOINT = 640;
17
+
12
18
  export type { DateRange };
13
19
 
14
20
  export interface DateRangePreset {
@@ -16,7 +22,10 @@ export interface DateRangePreset {
16
22
  getRange: () => DateRange;
17
23
  }
18
24
 
19
- export interface DateRangePickerProps {
25
+ export interface DateRangePickerProps extends Omit<
26
+ HTMLAttributes<HTMLButtonElement>,
27
+ "onSelect" | "children" | "value" | "defaultValue"
28
+ > {
20
29
  value?: DateRange;
21
30
  defaultValue?: DateRange;
22
31
  onValueChange?: (range?: DateRange) => void;
@@ -26,16 +35,6 @@ export interface DateRangePickerProps {
26
35
  className?: string;
27
36
  }
28
37
 
29
- const fmt = (date: Date) =>
30
- new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(date);
31
-
32
- function formatRange(range: DateRange | undefined, placeholder: string): string {
33
- if (!range) return placeholder;
34
- if (range.from && range.to) return `${fmt(range.from)} – ${fmt(range.to)}`;
35
- if (range.from) return `${fmt(range.from)} – …`;
36
- return placeholder;
37
- }
38
-
39
38
  /**
40
39
  * Date range picker (Popover + Calendar in range mode).
41
40
  * Supports optional preset quick-picks, controlled or uncontrolled.
@@ -50,24 +49,31 @@ export const DateRangePicker = forwardRef<HTMLButtonElement, DateRangePickerProp
50
49
  numberOfMonths = 2,
51
50
  placeholder = "Pick a date range",
52
51
  className,
52
+ ...props
53
53
  },
54
54
  ref,
55
55
  ) {
56
- const [internal, setInternal] = useState<DateRange | undefined>(defaultValue);
57
-
58
- const isControlled = value !== undefined;
59
- const range = isControlled ? value : internal;
56
+ const { formatDate } = useLocale();
57
+ const [range, setRange] = useControllableState<DateRange | undefined>(
58
+ value,
59
+ defaultValue,
60
+ onValueChange,
61
+ );
60
62
 
61
- const setRange = (r?: DateRange) => {
62
- if (!isControlled) {
63
- setInternal(r);
64
- }
65
- // Always fire the callback so uncontrolled consumers can observe changes
66
- onValueChange?.(r);
63
+ const fmt = (date: Date) => formatDate(date, { dateStyle: "medium" });
64
+ const formatRange = (r: DateRange | undefined): string => {
65
+ if (!r) return placeholder;
66
+ if (r.from && r.to) return `${fmt(r.from)} – ${fmt(r.to)}`;
67
+ if (r.from) return `${fmt(r.from)} …`;
68
+ return placeholder;
67
69
  };
68
70
 
69
71
  const hasRange = Boolean(range?.from);
70
- const label = formatRange(range, placeholder);
72
+ const label = formatRange(range);
73
+
74
+ // Below `sm`, two side-by-side months push the popover off-screen (#reviewed).
75
+ const isNarrow = useIsMobile(NARROW_BREAKPOINT);
76
+ const monthsToShow = isNarrow ? 1 : numberOfMonths;
71
77
 
72
78
  return (
73
79
  <Popover>
@@ -76,8 +82,9 @@ export const DateRangePicker = forwardRef<HTMLButtonElement, DateRangePickerProp
76
82
  ref={ref}
77
83
  variant="outline"
78
84
  aria-haspopup="dialog"
85
+ {...props}
79
86
  className={cn(
80
- "w-72 justify-start gap-2 text-start font-normal",
87
+ "w-full justify-start gap-2 text-start font-normal",
81
88
  !hasRange && "text-muted-foreground",
82
89
  className,
83
90
  )}
@@ -86,10 +93,14 @@ export const DateRangePicker = forwardRef<HTMLButtonElement, DateRangePickerProp
86
93
  {label}
87
94
  </Button>
88
95
  </PopoverTrigger>
89
- <PopoverContent className="w-auto p-0" align="start">
96
+ <PopoverContent
97
+ className="w-auto max-w-[calc(100vw-1rem)] p-0"
98
+ align="start"
99
+ collisionPadding={8}
100
+ >
90
101
  {presets && presets.length > 0 ? (
91
102
  <div className="flex">
92
- <div className="flex flex-col gap-1 border-r border-border p-2">
103
+ <div className="flex flex-col gap-1 border-e border-border p-2">
93
104
  {presets.map((preset) => (
94
105
  <Button
95
106
  key={preset.label}
@@ -104,7 +115,7 @@ export const DateRangePicker = forwardRef<HTMLButtonElement, DateRangePickerProp
104
115
  </div>
105
116
  <Calendar
106
117
  mode="range"
107
- numberOfMonths={numberOfMonths}
118
+ numberOfMonths={monthsToShow}
108
119
  selected={range}
109
120
  onSelect={setRange}
110
121
  autoFocus
@@ -113,7 +124,7 @@ export const DateRangePicker = forwardRef<HTMLButtonElement, DateRangePickerProp
113
124
  ) : (
114
125
  <Calendar
115
126
  mode="range"
116
- numberOfMonths={numberOfMonths}
127
+ numberOfMonths={monthsToShow}
117
128
  selected={range}
118
129
  onSelect={setRange}
119
130
  autoFocus
@@ -3,7 +3,7 @@ import { createContext, forwardRef, use, type HTMLAttributes } from "react";
3
3
  import { cva, type VariantProps } from "class-variance-authority";
4
4
  import { cn } from "../../lib/cn";
5
5
 
6
- const descriptionsVariants = cva("grid gap-x-6 gap-y-3 text-sm", {
6
+ const descriptionsVariants = cva("grid gap-x-6 gap-y-3 text-body", {
7
7
  variants: {
8
8
  columns: {
9
9
  1: "grid-cols-1",
@@ -47,11 +47,16 @@ describe("DialogContent size variants", () => {
47
47
  expect(content.className).toContain("max-w-3xl");
48
48
  });
49
49
 
50
- it("size='full' renders max-w-[95vw] and h-[90vh]", () => {
50
+ it("size='full' renders max-w-[95vw] and h-[90dvh] — dvh, not vh (mobile browser chrome)", () => {
51
51
  render(<Wrapper size="full" />);
52
52
  const content = screen.getByRole("dialog");
53
53
  expect(content.className).toContain("max-w-[95vw]");
54
- expect(content.className).toContain("h-[90vh]");
54
+ expect(content.className).toContain("h-[90dvh]");
55
+ expect(content.className).toContain("max-h-[90dvh]");
56
+ // `vh` on mobile Safari/Chrome is measured against the viewport with the
57
+ // browser chrome collapsed, so a plain `vh` value overflows past what is
58
+ // actually visible while the address bar is shown.
59
+ expect(content.className).not.toMatch(/(?<!d)vh\]/);
55
60
  });
56
61
  });
57
62
 
@@ -106,7 +111,7 @@ describe("DialogContent height/scroll regime (#341)", () => {
106
111
  // later variant utility, so `full` still lets its body own the scroll.
107
112
  expect(content.className).toContain("overflow-hidden");
108
113
  expect(content.className).not.toContain("overflow-y-auto");
109
- expect(content.className).toContain("max-h-[90vh]");
114
+ expect(content.className).toContain("max-h-[90dvh]");
110
115
  expect(content.className).not.toContain("max-h-[calc(100dvh-2rem)]");
111
116
  });
112
117
  });
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import {
2
4
  forwardRef,
3
5
  useCallback,
@@ -11,6 +13,7 @@ import {
11
13
  import * as DialogPrimitive from "@radix-ui/react-dialog";
12
14
  import { cva, type VariantProps } from "class-variance-authority";
13
15
  import { cn } from "../../lib/cn";
16
+ import { useLocale } from "../locale-provider";
14
17
  import { Heading, Text } from "../typography";
15
18
 
16
19
  /**
@@ -55,7 +58,11 @@ export const dialogContentVariants = cva(
55
58
  sm: "max-w-sm",
56
59
  lg: "max-w-lg",
57
60
  xl: "max-w-3xl",
58
- full: "max-w-[95vw] h-[90vh] max-h-[90vh] overflow-hidden",
61
+ // `dvh` (dynamic viewport height), not `vh`: on mobile Safari/Chrome
62
+ // `vh` is measured against the LARGEST viewport (browser chrome
63
+ // collapsed), so `size="full"` overflows past the visible viewport
64
+ // while the address bar is showing.
65
+ full: "max-w-[95vw] h-[90dvh] max-h-[90dvh] overflow-hidden",
59
66
  },
60
67
  },
61
68
  defaultVariants: { size: "lg" },
@@ -76,7 +83,7 @@ export const DialogOverlay = forwardRef<
76
83
  ref={ref}
77
84
  data-slot="dialog-overlay"
78
85
  className={cn(
79
- "fixed inset-0 z-50 bg-foreground/50 backdrop-blur-sm",
86
+ "fixed inset-0 z-50 bg-overlay backdrop-blur-overlay",
80
87
  "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
81
88
  className,
82
89
  )}
@@ -90,6 +97,7 @@ export const DialogContent = forwardRef<
90
97
  ComponentPropsWithoutRef<typeof DialogPrimitive.Content> &
91
98
  VariantProps<typeof dialogContentVariants>
92
99
  >(function DialogContent({ className, children, size, onOpenAutoFocus, ...props }, ref) {
100
+ const { t } = useLocale();
93
101
  const contentRef = useRef<HTMLDivElement | null>(null);
94
102
  const setRefs = useCallback(
95
103
  (node: HTMLDivElement | null) => {
@@ -150,7 +158,7 @@ export const DialogContent = forwardRef<
150
158
  <DialogPrimitive.Close
151
159
  data-slot="dialog-close"
152
160
  className="absolute end-4 top-4 rounded-md p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 focus-ring"
153
- aria-label="Close"
161
+ aria-label={t("close")}
154
162
  >
155
163
  <svg
156
164
  width="16"
@@ -213,7 +221,7 @@ export const DialogDescription = forwardRef<
213
221
  <DialogPrimitive.Description
214
222
  ref={ref}
215
223
  data-slot="dialog-description"
216
- className={cn("text-sm text-muted-foreground", className)}
224
+ className={cn("text-body text-muted-foreground", className)}
217
225
  {...props}
218
226
  />
219
227
  );
@@ -25,7 +25,8 @@ const DrawerOverlay = forwardRef<
25
25
  return (
26
26
  <DrawerPrimitive.Overlay
27
27
  ref={ref}
28
- className={cn("fixed inset-0 z-50 bg-foreground/50", className)}
28
+ data-slot="drawer-overlay"
29
+ className={cn("fixed inset-0 z-50 bg-overlay", className)}
29
30
  {...props}
30
31
  />
31
32
  );
@@ -40,13 +41,14 @@ export const DrawerContent = forwardRef<
40
41
  <DrawerOverlay />
41
42
  <DrawerPrimitive.Content
42
43
  ref={ref}
44
+ data-slot="drawer-content"
43
45
  className={cn(
44
46
  "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col overscroll-contain rounded-t-xl border bg-card text-card-foreground",
45
47
  className,
46
48
  )}
47
49
  {...props}
48
50
  >
49
- <div className="mx-auto mt-4 h-1.5 w-12 rounded-full bg-muted" />
51
+ <div data-slot="drawer-handle" className="mx-auto mt-4 h-1.5 w-12 rounded-full bg-muted" />
50
52
  {children}
51
53
  </DrawerPrimitive.Content>
52
54
  </DrawerPortal>
@@ -54,19 +56,33 @@ export const DrawerContent = forwardRef<
54
56
  });
55
57
 
56
58
  export function DrawerHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
57
- return <div className={cn("grid gap-1.5 p-4 text-center sm:text-start", className)} {...props} />;
59
+ return (
60
+ <div
61
+ data-slot="drawer-header"
62
+ className={cn("grid gap-1.5 p-4 text-center sm:text-start", className)}
63
+ {...props}
64
+ />
65
+ );
58
66
  }
59
67
  export function DrawerFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
60
- return <div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />;
68
+ return (
69
+ <div
70
+ data-slot="drawer-footer"
71
+ className={cn("mt-auto flex flex-col gap-2 p-4", className)}
72
+ {...props}
73
+ />
74
+ );
61
75
  }
62
76
  export const DrawerTitle = forwardRef<
63
77
  ElementRef<typeof DrawerPrimitive.Title>,
64
78
  ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
65
79
  >(function DrawerTitle({ className, ...props }, ref) {
66
80
  return (
81
+ // `text-title`, matching `DialogTitle` (#286) — was `text-lg font-semibold`.
67
82
  <DrawerPrimitive.Title
68
83
  ref={ref}
69
- className={cn("text-lg font-semibold", className)}
84
+ data-slot="drawer-title"
85
+ className={cn("text-title leading-none", className)}
70
86
  {...props}
71
87
  />
72
88
  );
@@ -78,7 +94,8 @@ export const DrawerDescription = forwardRef<
78
94
  return (
79
95
  <DrawerPrimitive.Description
80
96
  ref={ref}
81
- className={cn("text-sm text-muted-foreground", className)}
97
+ data-slot="drawer-description"
98
+ className={cn("text-body text-muted-foreground", className)}
82
99
  {...props}
83
100
  />
84
101
  );
@@ -109,7 +109,7 @@ export const DropdownMenuContent = forwardRef<
109
109
  ref={ref}
110
110
  sideOffset={sideOffset}
111
111
  className={cn(
112
- "z-50 min-w-[10rem] overflow-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-ring-md",
112
+ "z-50 min-w-[12rem] overflow-hidden rounded-md bg-popover p-1 text-popover-foreground shadow-ring-md",
113
113
  "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",
114
114
  "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",
115
115
  className,
@@ -128,7 +128,7 @@ export const DropdownMenuItem = forwardRef<
128
128
  <DropdownMenuPrimitive.Item
129
129
  ref={ref}
130
130
  className={cn(
131
- "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",
131
+ "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",
132
132
  "focus:bg-accent focus:text-accent-foreground",
133
133
  "data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
134
134
  "[&_svg]:size-4 [&_svg]:shrink-0",
@@ -147,7 +147,7 @@ export const DropdownMenuLabel = forwardRef<
147
147
  return (
148
148
  <DropdownMenuPrimitive.Label
149
149
  ref={ref}
150
- className={cn("px-2 py-1.5 text-xs font-semibold text-muted-foreground", className)}
150
+ className={cn("px-2 py-1.5 text-meta font-semibold text-muted-foreground", className)}
151
151
  {...props}
152
152
  />
153
153
  );
@@ -172,7 +172,7 @@ export function DropdownMenuShortcut({
172
172
  }: React.HTMLAttributes<HTMLSpanElement>) {
173
173
  return (
174
174
  <span
175
- className={cn("ms-auto text-xs tracking-widest text-muted-foreground", className)}
175
+ className={cn("ms-auto text-meta tracking-widest text-muted-foreground", className)}
176
176
  {...props}
177
177
  />
178
178
  );
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import {
2
4
  cloneElement,
3
5
  forwardRef,
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import {
2
4
  cloneElement,
3
5
  forwardRef,
@@ -15,21 +15,28 @@ function makeUploadFile(name = "test.txt", size = 1024): UploadFile {
15
15
  }
16
16
 
17
17
  describe("FileUpload", () => {
18
- it("renders the dropzone with a label pointing to the hidden input", () => {
18
+ it("default dropzone exposes exactly one control: the Browse files button", () => {
19
19
  render(
20
20
  <FileUpload>
21
21
  <FileUploadDropzone />
22
22
  </FileUpload>,
23
23
  );
24
- // The label wraps the input — label's htmlFor matches input's id
25
- const label = document.querySelector("label[data-slot='file-upload-dropzone']");
26
- expect(label).not.toBeNull();
27
- const inputId = label!.getAttribute("for");
28
- expect(inputId).toBeTruthy();
29
- const input = document.getElementById(inputId!);
30
- expect(input).not.toBeNull();
31
- expect(input!.tagName.toLowerCase()).toBe("input");
32
- expect(input).toHaveAttribute("type", "file");
24
+ const dropzone = document.querySelector("[data-slot='file-upload-dropzone']")!;
25
+ // The zone is a plain drop surface — no role, no tab stop — so the inner
26
+ // button is never nested inside another interactive element (axe
27
+ // `nested-interactive` / `aria-allowed-role`).
28
+ expect(dropzone.tagName.toLowerCase()).toBe("div");
29
+ expect(dropzone).not.toHaveAttribute("role");
30
+ expect(dropzone).not.toHaveAttribute("tabIndex");
31
+ expect(screen.getAllByRole("button")).toHaveLength(1);
32
+
33
+ const input = document.querySelector("input[type='file']") as HTMLInputElement;
34
+ const clickSpy = vi.spyOn(input, "click");
35
+ fireEvent.click(screen.getByRole("button", { name: "Browse files" }));
36
+ // One activation opens the picker once (the zone's click must not re-fire it).
37
+ expect(clickSpy).toHaveBeenCalledTimes(1);
38
+ fireEvent.click(dropzone);
39
+ expect(clickSpy).toHaveBeenCalledTimes(2);
33
40
  });
34
41
 
35
42
  it("hidden file input has sr-only class (visually hidden but real)", () => {
@@ -156,6 +163,72 @@ describe("FileUpload", () => {
156
163
  expect(onFilesChange).toHaveBeenCalledWith([]);
157
164
  });
158
165
 
166
+ it("drag-and-drop rejects files that don't match `accept` and reports why", () => {
167
+ const onFilesChange = vi.fn();
168
+ const onFilesRejected = vi.fn();
169
+ render(
170
+ <FileUpload
171
+ files={[]}
172
+ onFilesChange={onFilesChange}
173
+ onFilesRejected={onFilesRejected}
174
+ accept=".png"
175
+ >
176
+ <FileUploadDropzone />
177
+ </FileUpload>,
178
+ );
179
+ const dropzone = document.querySelector("[data-slot='file-upload-dropzone']")!;
180
+ const wrongType = new File(["x"], "notes.txt", { type: "text/plain" });
181
+ fireEvent.drop(dropzone, { dataTransfer: { files: [wrongType] } });
182
+
183
+ // A drop bypasses the native <input accept> filter entirely — without
184
+ // this, a dragged-in .txt would be added even though the picker would
185
+ // never have offered it.
186
+ expect(onFilesChange).toHaveBeenCalledWith([]);
187
+ expect(onFilesRejected).toHaveBeenCalledWith([{ file: wrongType, reason: "accept" }]);
188
+ });
189
+
190
+ it("drag-and-drop of several files when multiple=false keeps only the first, rejects the rest", () => {
191
+ const onFilesChange = vi.fn();
192
+ const onFilesRejected = vi.fn();
193
+ render(
194
+ <FileUpload files={[]} onFilesChange={onFilesChange} onFilesRejected={onFilesRejected}>
195
+ <FileUploadDropzone />
196
+ </FileUpload>,
197
+ );
198
+ const dropzone = document.querySelector("[data-slot='file-upload-dropzone']")!;
199
+ const first = new File(["a"], "a.txt", { type: "text/plain" });
200
+ const second = new File(["b"], "b.txt", { type: "text/plain" });
201
+ fireEvent.drop(dropzone, { dataTransfer: { files: [first, second] } });
202
+
203
+ // `multiple` is only enforced by the browser for the picker dialog, never
204
+ // for a raw DataTransfer drop.
205
+ expect(onFilesChange.mock.calls[0]![0]).toHaveLength(1);
206
+ expect(onFilesChange.mock.calls[0]![0][0].file.name).toBe("a.txt");
207
+ expect(onFilesRejected).toHaveBeenCalledWith([{ file: second, reason: "multiple" }]);
208
+ });
209
+
210
+ it("a custom-children dropzone still opens the picker on Enter/Space (keyboard reachable)", () => {
211
+ render(
212
+ <FileUpload>
213
+ <FileUploadDropzone>Drop it here</FileUploadDropzone>
214
+ </FileUpload>,
215
+ );
216
+ // With custom `children` there is no fallback "Browse files" button —
217
+ // the zone itself must be the one control: a tab stop answering Enter/Space.
218
+ const dropzone = screen.getByRole("button");
219
+ expect(dropzone).toHaveTextContent("Drop it here");
220
+ expect(dropzone).toHaveAttribute("data-slot", "file-upload-dropzone");
221
+ expect(dropzone).toHaveAttribute("tabIndex", "0");
222
+
223
+ const input = document.querySelector("input[type='file']") as HTMLInputElement;
224
+ const clickSpy = vi.spyOn(input, "click");
225
+ fireEvent.keyDown(dropzone, { key: "Enter" });
226
+ expect(clickSpy).toHaveBeenCalledTimes(1);
227
+
228
+ fireEvent.keyDown(dropzone, { key: " " });
229
+ expect(clickSpy).toHaveBeenCalledTimes(2);
230
+ });
231
+
159
232
  it("disabled prop disables the file input and remove buttons", () => {
160
233
  const f = makeUploadFile();
161
234
  render(