@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
@@ -18,6 +18,7 @@ import {
18
18
  import { X, Upload, FileIcon, CheckCircle2, AlertCircle } from "lucide-react";
19
19
  import { cn } from "../../lib/cn";
20
20
  import { Button } from "../button";
21
+ import { useLocale } from "../locale-provider";
21
22
  import { Progress } from "../progress";
22
23
 
23
24
  // ---------------------------------------------------------------------------
@@ -39,6 +40,42 @@ export interface UploadFile {
39
40
  errorMessage?: string;
40
41
  }
41
42
 
43
+ /** Why `addFiles` (picker OR drag-and-drop) declined a file. */
44
+ export type FileRejectionReason = "accept" | "maxSize" | "maxFiles" | "multiple";
45
+
46
+ export interface FileRejection {
47
+ file: File;
48
+ reason: FileRejectionReason;
49
+ }
50
+
51
+ /**
52
+ * Match a file against an `<input accept>`-style pattern list (comma-separated
53
+ * extensions like `.png`, exact MIME types like `image/png`, or wildcards like
54
+ * `image/*`). Drag-and-drop delivers files straight from `DataTransfer` — the
55
+ * browser only enforces `accept` for the native picker dialog, never for a
56
+ * drop — so this has to be re-applied by hand for the drop path to actually
57
+ * "respect `accept`".
58
+ */
59
+ function fileMatchesAccept(file: File, accept: string | undefined): boolean {
60
+ if (!accept) return true;
61
+ const patterns = accept
62
+ .split(",")
63
+ .map((p) => p.trim().toLowerCase())
64
+ .filter(Boolean);
65
+ if (patterns.length === 0) return true;
66
+ const name = file.name.toLowerCase();
67
+ const type = file.type.toLowerCase();
68
+ return patterns.some((pattern) => {
69
+ if (pattern.startsWith(".")) return name.endsWith(pattern);
70
+ if (pattern.endsWith("/*")) return type.startsWith(pattern.slice(0, -1));
71
+ return type === pattern;
72
+ });
73
+ }
74
+
75
+ function makeUploadFileId(file: File): string {
76
+ return `${file.name}-${Date.now()}-${Math.random()}`;
77
+ }
78
+
42
79
  // ---------------------------------------------------------------------------
43
80
  // Context
44
81
  // ---------------------------------------------------------------------------
@@ -58,6 +95,22 @@ interface FileUploadContextValue {
58
95
  inputId: string;
59
96
  }
60
97
 
98
+ /** Read `accept`/`multiple`/`maxSize`/`maxFiles` off a rejection for a default message. */
99
+ export function describeFileRejection(rejection: FileRejection): string {
100
+ switch (rejection.reason) {
101
+ case "accept":
102
+ return `${rejection.file.name} is not an accepted file type.`;
103
+ case "maxSize":
104
+ return `${rejection.file.name} is too large.`;
105
+ case "maxFiles":
106
+ return `${rejection.file.name} was not added — the file limit was reached.`;
107
+ case "multiple":
108
+ return `${rejection.file.name} was not added — only one file is allowed.`;
109
+ default:
110
+ return `${rejection.file.name} was not added.`;
111
+ }
112
+ }
113
+
61
114
  const FileUploadContext = createContext<FileUploadContextValue | null>(null);
62
115
 
63
116
  function useFileUpload(): FileUploadContextValue {
@@ -75,6 +128,12 @@ export interface FileUploadProps extends HTMLAttributes<HTMLDivElement> {
75
128
  files?: UploadFile[];
76
129
  /** Called when files are added or removed. */
77
130
  onFilesChange?: (files: UploadFile[]) => void;
131
+ /**
132
+ * Called with the files a picker selection OR a drag-and-drop declined —
133
+ * wrong type (`accept`), over `maxSize`, over `maxFiles`, or extra files
134
+ * dropped when `multiple` is false — each with its {@link FileRejectionReason}.
135
+ */
136
+ onFilesRejected?: (rejections: FileRejection[]) => void;
78
137
  /** Accepted MIME types / extensions (forwarded to <input accept>). */
79
138
  accept?: string;
80
139
  /** Allow multiple files at once. */
@@ -102,6 +161,7 @@ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(function F
102
161
  {
103
162
  files: filesProp,
104
163
  onFilesChange,
164
+ onFilesRejected,
105
165
  accept,
106
166
  multiple = false,
107
167
  maxSize,
@@ -134,19 +194,45 @@ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(function F
134
194
 
135
195
  const addFiles = useCallback(
136
196
  (incoming: File[]) => {
137
- const toAdd = incoming.filter((f) => {
138
- if (maxSize && f.size > maxSize) return false;
139
- return true;
140
- });
197
+ const rejections: FileRejection[] = [];
198
+
199
+ // A drop can deliver more files than the native picker ever would —
200
+ // `multiple` is only enforced by the browser for the `<input>` dialog,
201
+ // never for `DataTransfer`, so re-apply it here for the drop path too.
202
+ let candidates = incoming;
203
+ if (!multiple && candidates.length > 1) {
204
+ rejections.push(
205
+ ...candidates.slice(1).map((file) => ({ file, reason: "multiple" as const })),
206
+ );
207
+ candidates = candidates.slice(0, 1);
208
+ }
209
+
210
+ const toAdd: File[] = [];
211
+ for (const f of candidates) {
212
+ if (!fileMatchesAccept(f, accept)) {
213
+ rejections.push({ file: f, reason: "accept" });
214
+ continue;
215
+ }
216
+ if (maxSize && f.size > maxSize) {
217
+ rejections.push({ file: f, reason: "maxSize" });
218
+ continue;
219
+ }
220
+ toAdd.push(f);
221
+ }
141
222
 
142
223
  const next = [...files];
143
224
  for (const f of toAdd) {
144
- if (maxFiles && next.length >= maxFiles) break;
145
- next.push({ id: `${f.name}-${Date.now()}-${Math.random()}`, file: f });
225
+ if (maxFiles && next.length >= maxFiles) {
226
+ rejections.push({ file: f, reason: "maxFiles" });
227
+ continue;
228
+ }
229
+ next.push({ id: makeUploadFileId(f), file: f });
146
230
  }
231
+
147
232
  commit(next);
233
+ if (rejections.length > 0) onFilesRejected?.(rejections);
148
234
  },
149
- [files, maxSize, maxFiles, commit],
235
+ [files, accept, multiple, maxSize, maxFiles, commit, onFilesRejected],
150
236
  );
151
237
 
152
238
  const removeFile = useCallback(
@@ -256,30 +342,61 @@ export const FileUpload = forwardRef<HTMLDivElement, FileUploadProps>(function F
256
342
  // FileUploadDropzone
257
343
  // ---------------------------------------------------------------------------
258
344
 
259
- export interface FileUploadDropzoneProps extends HTMLAttributes<HTMLElement> {
260
- /** Label for the visually-hidden file picker link (for SR users). */
345
+ export interface FileUploadDropzoneProps extends HTMLAttributes<HTMLDivElement> {
346
+ /** Label for the default "Browse files" button. */
261
347
  browseLabel?: string;
262
348
  }
263
349
 
264
350
  /**
265
351
  * FileUploadDropzone — the drag-and-drop target.
266
352
  *
267
- * Uses a `<label>` wrapping the hidden `<input>` so the ENTIRE zone is ONE hit
268
- * target — no `<div onClick>`. Keyboard users get the Browse button as a
269
- * complementary path.
353
+ * A plain `<div>` drop surface with exactly ONE keyboard-reachable control:
354
+ *
355
+ * - Default content: a real `<button>` ("Browse files") is the tab stop; the
356
+ * zone itself carries no role and no tab stop, so nothing interactive is
357
+ * nested inside another interactive element.
358
+ * - Custom `children`: there is no inner button, so the zone itself becomes
359
+ * the control (`role="button"`, a tab stop, Enter/Space). Custom children
360
+ * must then be non-interactive content.
361
+ *
362
+ * A pointer click anywhere on the zone also opens the picker — a mouse
363
+ * convenience on top of the keyboard path above, never the only path.
270
364
  */
271
- export const FileUploadDropzone = forwardRef<HTMLElement, FileUploadDropzoneProps>(
365
+ export const FileUploadDropzone = forwardRef<HTMLDivElement, FileUploadDropzoneProps>(
272
366
  function FileUploadDropzone(
273
- { className, children, browseLabel = "Browse files", ...props },
367
+ { className, children, browseLabel, onClick, onKeyDown, ...props },
274
368
  ref,
275
369
  ) {
276
- const { isDragging, disabled, inputId, openPicker } = useFileUpload();
370
+ const { isDragging, disabled, openPicker } = useFileUpload();
371
+ const { t } = useLocale();
372
+ const resolvedBrowseLabel = browseLabel ?? t("ui.fileUpload.browseFiles");
373
+ // With custom children there is no inner button, so the zone owns the
374
+ // one keyboard path itself.
375
+ const isSelfControl = children != null;
277
376
 
278
377
  return (
279
- // label wraps the hidden input — clicking anywhere on the zone opens the picker
280
- <label
281
- ref={ref as React.Ref<HTMLLabelElement>}
282
- htmlFor={inputId}
378
+ <div
379
+ ref={ref}
380
+ role={isSelfControl ? "button" : undefined}
381
+ tabIndex={isSelfControl && !disabled ? 0 : undefined}
382
+ // Also on the default (role-less) zone: it marks the dimmed copy as
383
+ // inactive UI, which WCAG 1.4.3 exempts from contrast — the same
384
+ // exemption a `<label>` of a disabled control gets.
385
+ aria-disabled={disabled || undefined}
386
+ onClick={(e) => {
387
+ onClick?.(e);
388
+ if (disabled || e.defaultPrevented) return;
389
+ openPicker();
390
+ }}
391
+ onKeyDown={(e) => {
392
+ onKeyDown?.(e);
393
+ if (!isSelfControl || disabled || e.defaultPrevented) return;
394
+ if (e.target !== e.currentTarget) return;
395
+ if (e.key === "Enter" || e.key === " ") {
396
+ e.preventDefault();
397
+ openPicker();
398
+ }
399
+ }}
283
400
  data-slot="file-upload-dropzone"
284
401
  data-dragging={isDragging || undefined}
285
402
  data-disabled={disabled || undefined}
@@ -300,14 +417,18 @@ export const FileUploadDropzone = forwardRef<HTMLElement, FileUploadDropzoneProp
300
417
  <Upload className="size-5 text-muted-foreground" aria-hidden="true" />
301
418
  </div>
302
419
  <div className="flex flex-col items-center gap-1 text-center">
303
- <p className="text-sm font-medium text-foreground">Drag & drop files here</p>
304
- <p className="text-xs text-muted-foreground">
420
+ <p className="text-body font-medium text-foreground">
421
+ {t("ui.fileUpload.dragDropHere")}
422
+ </p>
423
+ <p className="text-meta text-muted-foreground">
305
424
  or{" "}
306
425
  <button
307
426
  type="button"
308
427
  disabled={disabled}
309
428
  onClick={(e) => {
310
- e.preventDefault();
429
+ // The zone's own click handler opens the picker; stop the
430
+ // bubble so one activation opens it exactly once.
431
+ e.stopPropagation();
311
432
  openPicker();
312
433
  }}
313
434
  className={cn(
@@ -320,13 +441,13 @@ export const FileUploadDropzone = forwardRef<HTMLElement, FileUploadDropzoneProp
320
441
  "disabled:pointer-events-none disabled:opacity-50",
321
442
  )}
322
443
  >
323
- {browseLabel}
444
+ {resolvedBrowseLabel}
324
445
  </button>
325
446
  </p>
326
447
  </div>
327
448
  </>
328
449
  )}
329
- </label>
450
+ </div>
330
451
  );
331
452
  },
332
453
  );
@@ -344,6 +465,7 @@ export type FileUploadListProps = HTMLAttributes<HTMLUListElement>;
344
465
  export const FileUploadList = forwardRef<HTMLUListElement, FileUploadListProps>(
345
466
  function FileUploadList({ className, children, ...props }, ref) {
346
467
  const { files } = useFileUpload();
468
+ const { t } = useLocale();
347
469
  if (files.length === 0 && !children) return null;
348
470
 
349
471
  return (
@@ -355,7 +477,7 @@ export const FileUploadList = forwardRef<HTMLUListElement, FileUploadListProps>(
355
477
  <ul
356
478
  ref={ref}
357
479
  aria-live="polite"
358
- aria-label="Selected files"
480
+ aria-label={t("ui.fileUpload.selectedFiles")}
359
481
  data-slot="file-upload-list"
360
482
  className={cn("flex flex-col gap-2", className)}
361
483
  {...props}
@@ -420,10 +542,10 @@ export const FileUploadItem = forwardRef<HTMLLIElement, FileUploadItemProps>(
420
542
  >
421
543
  <div className="flex items-center gap-2">
422
544
  {statusIcon[resolvedStatus]}
423
- <span className="flex-1 min-w-0 truncate text-sm text-foreground">
545
+ <span className="flex-1 min-w-0 truncate text-body text-foreground">
424
546
  {uploadFile.file.name}
425
547
  </span>
426
- <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
548
+ <span className="shrink-0 text-meta tabular-nums text-muted-foreground">
427
549
  {formatFileSize(uploadFile.file.size)}
428
550
  </span>
429
551
  <Button
@@ -445,7 +567,7 @@ export const FileUploadItem = forwardRef<HTMLLIElement, FileUploadItemProps>(
445
567
  // #124: this is running text (the rejection reason), so it takes the
446
568
  // >=4.5:1 ink rung `text-destructive-text` — not the 3:1 mark rung the
447
569
  // `statusIcon.error` glyph above correctly keeps.
448
- <p role="alert" className="text-xs text-destructive-text">
570
+ <p role="alert" className="text-meta text-destructive-text">
449
571
  {resolvedError}
450
572
  </p>
451
573
  )}
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import {
2
4
  createContext,
3
5
  forwardRef,
@@ -114,7 +116,7 @@ export const FormDescription = forwardRef<
114
116
  <p
115
117
  ref={ref}
116
118
  id={formDescriptionId}
117
- className={cn("text-sm text-muted-foreground", className)}
119
+ className={cn("text-body text-muted-foreground", className)}
118
120
  {...props}
119
121
  />
120
122
  );
@@ -132,7 +134,7 @@ export const FormMessage = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLP
132
134
  className={cn(
133
135
  // #124: the error message body is running text — ink rung, not the
134
136
  // 3:1 mark rung `text-destructive` guarantees.
135
- "text-sm font-medium text-destructive-text animate-in fade-in slide-in-from-top-1 ease-entrance",
137
+ "text-body font-medium text-destructive-text animate-in fade-in slide-in-from-top-1 ease-entrance",
136
138
  className,
137
139
  )}
138
140
  {...props}
@@ -12,6 +12,7 @@ export const HoverCardContent = forwardRef<
12
12
  return (
13
13
  <HoverCardPrimitive.Content
14
14
  ref={ref}
15
+ data-slot="hover-card-content"
15
16
  align={align}
16
17
  sideOffset={sideOffset}
17
18
  className={cn(
@@ -97,7 +97,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
97
97
  data-slot="icon-button"
98
98
  tabIndex={disabled ? 0 : undefined}
99
99
  className={cn(
100
- "inline-flex rounded-md",
100
+ "inline-flex rounded-control",
101
101
  // While disabled this span IS the focus target (the button is out
102
102
  // of the tab order), so it must carry the system's focus ring —
103
103
  // without this it falls back to the UA default outline, which is
@@ -12,12 +12,16 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
12
12
  ref={ref}
13
13
  type={type}
14
14
  className={cn(
15
- "flex h-9 w-full rounded-md border border-input bg-background text-foreground px-3 py-1 text-sm shadow-sm transition-[color,background-color,border-color,box-shadow] duration-fast ease-standard",
15
+ "flex h-control w-full rounded-control border border-input bg-input-background text-foreground px-3 py-1 text-body shadow-input transition-[color,background-color,border-color,box-shadow] duration-fast ease-standard",
16
16
  "placeholder:text-muted-foreground",
17
17
  "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
18
- "disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-muted disabled:border-border",
18
+ // One disabled mechanism (opacity fade), matching every `Button`
19
+ // variant — `disabled:bg-muted` used to also fire here, stacking a
20
+ // fill swap on top of the fade (#286); `disabled:border-border` stays,
21
+ // it is the separate/subtle "not editable" edge, not a fill.
22
+ "disabled:cursor-not-allowed disabled:opacity-50 disabled:border-border",
19
23
  "aria-[invalid=true]:border-destructive aria-[invalid=true]:ring-destructive",
20
- "file:border-0 file:bg-transparent file:text-sm file:font-medium",
24
+ "file:border-0 file:bg-transparent file:text-body file:font-medium",
21
25
  className,
22
26
  )}
23
27
  {...props}
@@ -7,11 +7,11 @@ import { Textarea } from "../textarea";
7
7
 
8
8
  export const inputGroupVariants = cva(
9
9
  cn(
10
- "group/input-group relative flex w-full flex-wrap items-center rounded-md transition-[color,box-shadow] duration-fast ease-standard",
10
+ "group/input-group relative flex w-full flex-wrap items-center rounded-control transition-[color,box-shadow] duration-fast ease-standard",
11
11
  // Grow to fit a textarea even when it's nested (e.g. wrapped in a
12
12
  // `display:contents` body like PromptInputBody). `:has(>textarea)`
13
13
  // misses through that wrapper, so match any descendant textarea.
14
- "h-9 has-[textarea]:h-auto",
14
+ "h-control has-[textarea]:h-auto",
15
15
  // The compound indicator is the wrapper's, not the control's — the control
16
16
  // is borderless and full-bleed, so a ring around IT would draw inside the
17
17
  // well. `focus-ring-static` paints BOTH layers; the only geometry knob is
@@ -29,7 +29,7 @@ export const inputGroupVariants = cva(
29
29
  variant: {
30
30
  // The form-field look: the strong `--input` boundary is the field's
31
31
  // sole structural cue, so it stays on the strong rung.
32
- outline: "border border-input shadow-sm",
32
+ outline: "border border-input shadow-input",
33
33
  // The composer look (#194, research 08 §C.1/§D): a soft fill with the
34
34
  // focus-within ring carrying focus — NO hard border. Separation comes
35
35
  // from the fill (the chat footer already draws its own `border-t`).
@@ -68,7 +68,7 @@ export const InputGroup = forwardRef<HTMLDivElement, InputGroupProps>(function I
68
68
  });
69
69
 
70
70
  export const inputGroupAddonVariants = cva(
71
- "flex h-auto cursor-text items-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none [&>svg:not([class*='size-'])]:size-4",
71
+ "flex h-auto cursor-text items-center gap-2 py-1.5 text-body font-medium text-muted-foreground select-none [&>svg:not([class*='size-'])]:size-4",
72
72
  {
73
73
  variants: {
74
74
  align: {
@@ -98,7 +98,7 @@ export const InputGroupAddon = forwardRef<
98
98
  );
99
99
  });
100
100
 
101
- const inputGroupButtonVariants = cva("flex items-center gap-2 text-sm shadow-none", {
101
+ const inputGroupButtonVariants = cva("flex items-center gap-2 text-body shadow-none", {
102
102
  variants: {
103
103
  size: {
104
104
  xs: "h-6 gap-1 rounded-sm px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
@@ -137,7 +137,7 @@ export const InputGroupText = forwardRef<HTMLSpanElement, ComponentProps<"span">
137
137
  <span
138
138
  ref={ref}
139
139
  className={cn(
140
- "flex items-center gap-2 text-sm text-muted-foreground [&_svg:not([class*='size-'])]:size-4",
140
+ "flex items-center gap-2 text-body text-muted-foreground [&_svg:not([class*='size-'])]:size-4",
141
141
  className,
142
142
  )}
143
143
  {...props}
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import { forwardRef, useContext, type ComponentPropsWithoutRef, type ElementRef } from "react";
2
4
  import { OTPInput, OTPInputContext } from "input-otp";
3
5
  import { Minus } from "lucide-react";
@@ -41,7 +43,7 @@ export const InputOTPSlot = forwardRef<
41
43
  <div
42
44
  ref={ref}
43
45
  className={cn(
44
- "relative flex size-9 items-center justify-center border-y border-e border-input text-sm shadow-sm transition-[color,border-color,box-shadow] duration-fast ease-standard first:rounded-s-md first:border-s last:rounded-e-md",
46
+ "relative flex size-9 items-center justify-center border-y border-e border-input text-body shadow-sm transition-[color,border-color,box-shadow] duration-fast ease-standard first:rounded-s-md first:border-s last:rounded-e-md",
45
47
  // The OTP slot IS the field's focus indicator: the real focused element is
46
48
  // InputOTP's visually-hidden <input>, and `isActive` is the caret's slot. It
47
49
  // therefore carries the compound indicator (#67) — the state is JS-driven, so
@@ -101,7 +101,7 @@ export const KeyboardShortcuts = forwardRef<HTMLDivElement, KeyboardShortcutsPro
101
101
  </label>
102
102
  <Search
103
103
  aria-hidden="true"
104
- className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
104
+ className="pointer-events-none absolute start-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
105
105
  />
106
106
  <Input
107
107
  id={searchId}
@@ -110,7 +110,7 @@ export const KeyboardShortcuts = forwardRef<HTMLDivElement, KeyboardShortcutsPro
110
110
  placeholder={t("ui.keyboardShortcuts.searchPlaceholder")}
111
111
  value={query}
112
112
  onChange={(event) => setQuery(event.target.value)}
113
- className="pl-8"
113
+ className="ps-8"
114
114
  />
115
115
  </div>
116
116
  )}
@@ -10,7 +10,7 @@ export const Label = forwardRef<
10
10
  <LabelPrimitive.Root
11
11
  ref={ref}
12
12
  className={cn(
13
- "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
13
+ "text-body font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
14
14
  className,
15
15
  )}
16
16
  {...props}
@@ -69,6 +69,30 @@ describe("ListEditor", () => {
69
69
  expect(onValueChange).toHaveBeenCalledWith(["b", "a"]);
70
70
  });
71
71
 
72
+ it("keeps keyboard focus on the moved row's own move-down button after a non-boundary move", async () => {
73
+ render(<ListEditor defaultValue={["a", "b", "c", "d"]} />);
74
+ const moveDown = screen.getByRole("button", { name: "Move item 2 down" });
75
+ moveDown.focus();
76
+ await userEvent.keyboard("{Enter}");
77
+ // Row "b" moved from position 2 to position 3 — index-keyed rows would
78
+ // leave focus sitting on whatever the DOM slot at the old position now
79
+ // renders (the row that got swapped INTO it), not on the row the user
80
+ // actually moved.
81
+ expect(screen.getByRole("button", { name: "Move item 3 down" })).toHaveFocus();
82
+ });
83
+
84
+ it("redirects focus to the complementary button when a move disables the one just pressed", async () => {
85
+ render(<ListEditor defaultValue={["a", "b", "c"]} />);
86
+ const moveUp = screen.getByRole("button", { name: "Move item 2 up" });
87
+ moveUp.focus();
88
+ await userEvent.keyboard("{Enter}");
89
+ // "b" is now row 1 (the top) — its own "move up" is now disabled, and a
90
+ // browser blurs a focused element the moment it goes disabled. Focus
91
+ // must land on that same row's still-enabled "move down" button instead
92
+ // of falling through to <body>.
93
+ expect(screen.getByRole("button", { name: "Move item 1 down" })).toHaveFocus();
94
+ });
95
+
72
96
  it("hides reorder buttons when reorderable=false", () => {
73
97
  render(<ListEditor defaultValue={["a", "b"]} reorderable={false} />);
74
98
  // Anchored at the start — "Remove item 1" contains the substring "move