@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,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import {
2
4
  createContext,
3
5
  forwardRef,
@@ -6,13 +8,29 @@ import {
6
8
  useEffect,
7
9
  useState,
8
10
  type ComponentProps,
11
+ type ElementRef,
9
12
  type HTMLAttributes,
10
13
  type KeyboardEvent,
11
14
  } from "react";
15
+ import { useDirection } from "@radix-ui/react-direction";
12
16
  import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
13
17
  import { ArrowLeft, ArrowRight } from "lucide-react";
14
18
  import { cn } from "../../lib/cn";
15
19
  import { Button } from "../button";
20
+ import { useLocale } from "../locale-provider";
21
+
22
+ /**
23
+ * True when the keyboard event's target already owns arrow-key semantics of
24
+ * its own (a text field's caret, a contenteditable's selection) — the
25
+ * carousel must not steal ArrowLeft/ArrowRight from a form control that
26
+ * happens to live inside the active slide.
27
+ */
28
+ function isEditableTarget(target: EventTarget | null): boolean {
29
+ if (!(target instanceof HTMLElement)) return false;
30
+ if (target.isContentEditable) return true;
31
+ const tag = target.tagName;
32
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
33
+ }
16
34
 
17
35
  type CarouselApi = UseEmblaCarouselType[1];
18
36
  type CarouselOptions = NonNullable<Parameters<typeof useEmblaCarousel>[0]>;
@@ -62,17 +80,20 @@ export const Carousel = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement
62
80
  // aria-roledescription changes the announcement, it does not name the
63
81
  // landmark. Default is overridable; pass a specific label ("Product
64
82
  // photos") when the carousel's purpose isn't generic. Issue #279.
65
- "aria-label": ariaLabel = "Carousel",
83
+ "aria-label": ariaLabel,
66
84
  ...props
67
85
  },
68
86
  ref,
69
87
  ) {
88
+ const { t } = useLocale();
89
+ const resolvedAriaLabel = ariaLabel ?? t("ui.carousel.label");
70
90
  const [carouselRef, api] = useEmblaCarousel(
71
91
  { ...opts, axis: orientation === "horizontal" ? "x" : "y" },
72
92
  plugins,
73
93
  );
74
94
  const [canScrollPrev, setCanScrollPrev] = useState(false);
75
95
  const [canScrollNext, setCanScrollNext] = useState(false);
96
+ const dir = useDirection();
76
97
 
77
98
  const onSelect = useCallback((a: CarouselApi) => {
78
99
  if (!a) return;
@@ -85,15 +106,42 @@ export const Carousel = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement
85
106
 
86
107
  const onKeyDown = useCallback(
87
108
  (e: KeyboardEvent<HTMLDivElement>) => {
109
+ // Don't steal ArrowLeft/ArrowRight from a text field's caret or a
110
+ // contenteditable's selection inside the active slide.
111
+ if (isEditableTarget(e.target)) return;
112
+
113
+ if (orientation === "vertical") {
114
+ if (e.key === "ArrowUp") {
115
+ e.preventDefault();
116
+ scrollPrev();
117
+ } else if (e.key === "ArrowDown") {
118
+ e.preventDefault();
119
+ scrollNext();
120
+ }
121
+ return;
122
+ }
123
+
124
+ // Horizontal: the PHYSICAL key maps to the visually-adjacent slide,
125
+ // which flips with writing direction — in RTL, ArrowLeft moves toward
126
+ // the next slide (visually to the left of the current one), not prev.
127
+ const isRtl = dir === "rtl";
88
128
  if (e.key === "ArrowLeft") {
89
129
  e.preventDefault();
90
- scrollPrev();
130
+ if (isRtl) {
131
+ scrollNext();
132
+ } else {
133
+ scrollPrev();
134
+ }
91
135
  } else if (e.key === "ArrowRight") {
92
136
  e.preventDefault();
93
- scrollNext();
137
+ if (isRtl) {
138
+ scrollPrev();
139
+ } else {
140
+ scrollNext();
141
+ }
94
142
  }
95
143
  },
96
- [scrollPrev, scrollNext],
144
+ [orientation, dir, scrollPrev, scrollNext],
97
145
  );
98
146
 
99
147
  useEffect(() => {
@@ -105,6 +153,7 @@ export const Carousel = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement
105
153
  api.on("reInit", onSelect);
106
154
  api.on("select", onSelect);
107
155
  return () => {
156
+ api.off("reInit", onSelect);
108
157
  api.off("select", onSelect);
109
158
  };
110
159
  }, [api, onSelect]);
@@ -128,7 +177,7 @@ export const Carousel = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement
128
177
  className={cn("relative", className)}
129
178
  role="region"
130
179
  aria-roledescription="carousel"
131
- aria-label={ariaLabel}
180
+ aria-label={resolvedAriaLabel}
132
181
  {...props}
133
182
  >
134
183
  {children}
@@ -176,20 +225,28 @@ export const CarouselItem = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivEle
176
225
  },
177
226
  );
178
227
 
179
- export function CarouselPrevious({ className, ...props }: ComponentProps<typeof Button>) {
228
+ export const CarouselPrevious = forwardRef<
229
+ ElementRef<typeof Button>,
230
+ ComponentProps<typeof Button>
231
+ >(function CarouselPrevious({ className, ...props }, ref) {
180
232
  const { scrollPrev, canScrollPrev, orientation } = useCarousel();
233
+ const { t } = useLocale();
181
234
  return (
182
235
  <Button
236
+ ref={ref}
183
237
  variant="outline"
184
238
  size="icon"
185
239
  disabled={!canScrollPrev}
186
240
  onClick={scrollPrev}
187
- aria-label="Previous slide"
241
+ aria-label={t("previousSlide")}
188
242
  className={cn(
189
243
  "absolute size-8 rounded-full",
244
+ // Inset on narrow screens (no room for the button to sit outside
245
+ // the carousel box without overflowing horizontally); pushed
246
+ // outside the box only from `sm` up, where there's margin for it.
190
247
  orientation === "horizontal"
191
- ? "-start-12 top-1/2 -translate-y-1/2"
192
- : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
248
+ ? "start-2 top-1/2 -translate-y-1/2 sm:-start-12"
249
+ : "top-2 left-1/2 -translate-x-1/2 rotate-90 sm:-top-12",
193
250
  className,
194
251
  )}
195
252
  {...props}
@@ -197,29 +254,33 @@ export function CarouselPrevious({ className, ...props }: ComponentProps<typeof
197
254
  <ArrowLeft className="size-4" data-rtl-flip />
198
255
  </Button>
199
256
  );
200
- }
257
+ });
201
258
 
202
- export function CarouselNext({ className, ...props }: ComponentProps<typeof Button>) {
203
- const { scrollNext, canScrollNext, orientation } = useCarousel();
204
- return (
205
- <Button
206
- variant="outline"
207
- size="icon"
208
- disabled={!canScrollNext}
209
- onClick={scrollNext}
210
- aria-label="Next slide"
211
- className={cn(
212
- "absolute size-8 rounded-full",
213
- orientation === "horizontal"
214
- ? "-end-12 top-1/2 -translate-y-1/2"
215
- : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
216
- className,
217
- )}
218
- {...props}
219
- >
220
- <ArrowRight className="size-4" data-rtl-flip />
221
- </Button>
222
- );
223
- }
259
+ export const CarouselNext = forwardRef<ElementRef<typeof Button>, ComponentProps<typeof Button>>(
260
+ function CarouselNext({ className, ...props }, ref) {
261
+ const { scrollNext, canScrollNext, orientation } = useCarousel();
262
+ const { t } = useLocale();
263
+ return (
264
+ <Button
265
+ ref={ref}
266
+ variant="outline"
267
+ size="icon"
268
+ disabled={!canScrollNext}
269
+ onClick={scrollNext}
270
+ aria-label={t("nextSlide")}
271
+ className={cn(
272
+ "absolute size-8 rounded-full",
273
+ orientation === "horizontal"
274
+ ? "end-2 top-1/2 -translate-y-1/2 sm:-end-12"
275
+ : "bottom-2 left-1/2 -translate-x-1/2 rotate-90 sm:-bottom-12",
276
+ className,
277
+ )}
278
+ {...props}
279
+ >
280
+ <ArrowRight className="size-4" data-rtl-flip />
281
+ </Button>
282
+ );
283
+ },
284
+ );
224
285
 
225
286
  export type { CarouselApi };
@@ -67,6 +67,7 @@ import { Badge } from "../badge/badge";
67
67
  import { StatePanel } from "../state-panel/state-panel";
68
68
  import { StatusIcon } from "../status-badge/status-badge";
69
69
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../collapsible/collapsible";
70
+ import { useLocale } from "../locale-provider";
70
71
 
71
72
  // ─── Public types ─────────────────────────────────────────────────────────────
72
73
 
@@ -283,6 +284,7 @@ export type ChangeReviewHeaderProps = HTMLAttributes<HTMLDivElement>;
283
284
  */
284
285
  export const ChangeReviewHeader = forwardRef<HTMLDivElement, ChangeReviewHeaderProps>(
285
286
  function ChangeReviewHeader({ className, ...props }, ref) {
287
+ const { t } = useLocale();
286
288
  const { state, actions, headingId } = useChangeReview();
287
289
  const { approvedCount, totalCount, allApproved, noneApproved } = state;
288
290
 
@@ -294,10 +296,10 @@ export const ChangeReviewHeader = forwardRef<HTMLDivElement, ChangeReviewHeaderP
294
296
  >
295
297
  <div className="flex items-center gap-2 min-w-0">
296
298
  <span id={headingId} className="text-subtitle font-semibold text-foreground truncate">
297
- Review changes
299
+ {t("ui.changeReview.heading")}
298
300
  </span>
299
301
  <Badge variant={allApproved ? "success" : approvedCount > 0 ? "info" : "secondary"}>
300
- {approvedCount} of {totalCount} approved
302
+ {t("ui.changeReview.approvedCount", { approved: approvedCount, total: totalCount })}
301
303
  </Badge>
302
304
  </div>
303
305
 
@@ -308,20 +310,20 @@ export const ChangeReviewHeader = forwardRef<HTMLDivElement, ChangeReviewHeaderP
308
310
  variant="outline-subtle"
309
311
  disabled={noneApproved}
310
312
  onClick={actions.rejectAll}
311
- aria-label="Reject all changes"
313
+ aria-label={t("ui.changeReview.rejectAllLabel")}
312
314
  >
313
315
  <X aria-hidden="true" />
314
- Reject all
316
+ {t("ui.changeReview.rejectAll")}
315
317
  </Button>
316
318
  <Button
317
319
  size="sm"
318
320
  variant={allApproved ? "secondary" : "default"}
319
321
  disabled={allApproved}
320
322
  onClick={actions.approveAll}
321
- aria-label="Approve all changes"
323
+ aria-label={t("ui.changeReview.approveAllLabel")}
322
324
  >
323
325
  <CheckCheck aria-hidden="true" />
324
- Approve all
326
+ {t("ui.changeReview.approveAll")}
325
327
  </Button>
326
328
  </div>
327
329
  )}
@@ -357,6 +359,7 @@ function isChangeProvenance(v: unknown): v is ChangeProvenance {
357
359
  */
358
360
  export const ChangeReviewProvenance = forwardRef<HTMLDivElement, ChangeReviewProvenanceProps>(
359
361
  function ChangeReviewProvenance({ provenance, className, ...props }, ref) {
362
+ const { t } = useLocale();
360
363
  if (isChangeProvenance(provenance)) {
361
364
  const { author, model, timestamp, note } = provenance;
362
365
  return (
@@ -367,7 +370,7 @@ export const ChangeReviewProvenance = forwardRef<HTMLDivElement, ChangeReviewPro
367
370
  className,
368
371
  )}
369
372
  role="group"
370
- aria-label="Change provenance"
373
+ aria-label={t("ui.changeReview.provenanceLabel")}
371
374
  {...props}
372
375
  >
373
376
  {author && (
@@ -399,7 +402,7 @@ export const ChangeReviewProvenance = forwardRef<HTMLDivElement, ChangeReviewPro
399
402
  ref={ref}
400
403
  className={cn("pb-3 text-caption text-muted-foreground", className)}
401
404
  role="group"
402
- aria-label="Change provenance"
405
+ aria-label={t("ui.changeReview.provenanceLabel")}
403
406
  {...props}
404
407
  >
405
408
  {provenance as ReactNode}
@@ -418,14 +421,15 @@ export type ChangeReviewListProps = HTMLAttributes<HTMLOListElement>;
418
421
  */
419
422
  export const ChangeReviewList = forwardRef<HTMLOListElement, ChangeReviewListProps>(
420
423
  function ChangeReviewList({ className, children, ...props }, ref) {
424
+ const { t } = useLocale();
421
425
  const { hunks, headingId } = useChangeReview();
422
426
 
423
427
  if (hunks.length === 0) {
424
428
  return (
425
429
  <StatePanel
426
430
  kind="empty"
427
- title="No changes to review"
428
- description="When an agent proposes edits, they'll appear here for your approval."
431
+ title={t("ui.changeReview.emptyTitle")}
432
+ description={t("ui.changeReview.emptyDescription")}
429
433
  className="my-2"
430
434
  />
431
435
  );
@@ -468,10 +472,10 @@ const HUNK_STATUS_ICONS: Record<ChangeHunkStatus, ReactNode> = {
468
472
  ),
469
473
  };
470
474
 
471
- const HUNK_STATUS_LABELS: Record<ChangeHunkStatus, string> = {
472
- added: "added",
473
- removed: "removed",
474
- modified: "modified",
475
+ const HUNK_STATUS_LABEL_KEYS: Record<ChangeHunkStatus, string> = {
476
+ added: "ui.changeReview.statusAdded",
477
+ removed: "ui.changeReview.statusRemoved",
478
+ modified: "ui.changeReview.statusModified",
475
479
  };
476
480
 
477
481
  // ─── Checks (verification evidence) ────────────────────────────────────────────
@@ -487,9 +491,9 @@ function isLongCheckDetail(detail: string): boolean {
487
491
  return detail.length > CHECK_DETAIL_COLLAPSE_THRESHOLD || detail.includes("\n");
488
492
  }
489
493
 
490
- const CHECK_PHASE_LABELS: Record<"before" | "after", string> = {
491
- before: "Before",
492
- after: "After",
494
+ const CHECK_PHASE_LABEL_KEYS: Record<"before" | "after", string> = {
495
+ before: "ui.changeReview.phaseBefore",
496
+ after: "ui.changeReview.phaseAfter",
493
497
  };
494
498
 
495
499
  /**
@@ -526,9 +530,10 @@ function groupChecksByPhase(
526
530
  * decorative (`aria-hidden`); the status word is what reaches assistive tech.
527
531
  */
528
532
  function ChangeCheckRow({ check }: { check: CheckResult }) {
533
+ const { t } = useLocale();
529
534
  const [detailOpen, setDetailOpen] = useState(false);
530
535
  const { label, ok, detail, durationMs } = check;
531
- const statusWord = ok ? "Passed" : "Failed";
536
+ const statusWord = ok ? t("ui.changeReview.checkPassed") : t("ui.changeReview.checkFailed");
532
537
  const hasDetail = detail !== undefined && detail !== "";
533
538
  const longDetail = hasDetail && isLongCheckDetail(detail);
534
539
 
@@ -552,7 +557,7 @@ function ChangeCheckRow({ check }: { check: CheckResult }) {
552
557
  <Collapsible open={detailOpen} onOpenChange={setDetailOpen}>
553
558
  <CollapsibleTrigger
554
559
  className={cn(
555
- "flex items-center gap-1 self-start rounded-sm pl-5 text-caption text-muted-foreground",
560
+ "flex items-center gap-1 self-start rounded-sm ps-5 text-caption text-muted-foreground",
556
561
  "hover:text-foreground focus-ring",
557
562
  )}
558
563
  >
@@ -563,16 +568,16 @@ function ChangeCheckRow({ check }: { check: CheckResult }) {
563
568
  detailOpen ? "rotate-180" : "rotate-0",
564
569
  )}
565
570
  />
566
- {detailOpen ? "Hide detail" : "Show detail"}
571
+ {detailOpen ? t("ui.changeReview.hideDetail") : t("ui.changeReview.showDetail")}
567
572
  </CollapsibleTrigger>
568
573
  <CollapsibleContent data-slot="change-review-hunk-check-detail">
569
- <p className="pl-5 pt-1 text-caption text-muted-foreground break-words">{detail}</p>
574
+ <p className="ps-5 pt-1 text-caption text-muted-foreground break-words">{detail}</p>
570
575
  </CollapsibleContent>
571
576
  </Collapsible>
572
577
  ) : (
573
578
  <p
574
579
  data-slot="change-review-hunk-check-detail"
575
- className="pl-5 text-caption text-muted-foreground break-words"
580
+ className="ps-5 text-caption text-muted-foreground break-words"
576
581
  >
577
582
  {detail}
578
583
  </p>
@@ -583,6 +588,7 @@ function ChangeCheckRow({ check }: { check: CheckResult }) {
583
588
 
584
589
  /** The full checks section for one hunk — grouped by phase when applicable. */
585
590
  function ChangeReviewChecks({ checks }: { checks: CheckResult[] }) {
591
+ const { t } = useLocale();
586
592
  const groups = groupChecksByPhase(checks);
587
593
  const showPhaseLabels = groups.some((g) => g.phase !== undefined);
588
594
 
@@ -599,7 +605,7 @@ function ChangeReviewChecks({ checks }: { checks: CheckResult[] }) {
599
605
  >
600
606
  {showPhaseLabels && group.phase && (
601
607
  <span className="text-meta font-medium text-muted-foreground">
602
- {CHECK_PHASE_LABELS[group.phase]}
608
+ {t(CHECK_PHASE_LABEL_KEYS[group.phase])}
603
609
  </span>
604
610
  )}
605
611
  <div className="flex flex-col gap-1.5">
@@ -636,12 +642,15 @@ export interface ChangeReviewHunkProps
636
642
  */
637
643
  export const ChangeReviewHunk = forwardRef<HTMLLIElement, ChangeReviewHunkProps>(
638
644
  function ChangeReviewHunk({ hunk, renderHunk, className, ...props }, ref) {
645
+ const { t } = useLocale();
639
646
  const { state, actions } = useChangeReview();
640
647
  const { id, title, before, after, status = "modified", provenance, checks } = hunk;
641
648
  const approved = state.approved.has(id);
642
649
 
643
- const statusLabel = HUNK_STATUS_LABELS[status];
644
- const toggleLabel = approved ? `Reject hunk: ${title ?? id}` : `Approve hunk: ${title ?? id}`;
650
+ const statusLabel = t(HUNK_STATUS_LABEL_KEYS[status]);
651
+ const toggleLabel = t(approved ? "ui.changeReview.rejectHunk" : "ui.changeReview.approveHunk", {
652
+ title: title ?? id,
653
+ });
645
654
 
646
655
  // Quiet, no-fill diff: before = struck muted text with a "−" marker, after =
647
656
  // foreground text with a "+" marker — separated by space, NOT colored
@@ -659,7 +668,7 @@ export const ChangeReviewHunk = forwardRef<HTMLLIElement, ChangeReviewHunkProps>
659
668
  >
660
669
 
661
670
  </span>
662
- <span className="sr-only">Before: </span>
671
+ <span className="sr-only">{t("ui.changeReview.beforePrefix")}</span>
663
672
  <div className="min-w-0 break-words text-muted-foreground line-through decoration-muted-foreground/30">
664
673
  {before}
665
674
  </div>
@@ -670,7 +679,7 @@ export const ChangeReviewHunk = forwardRef<HTMLLIElement, ChangeReviewHunkProps>
670
679
  <span aria-hidden="true" className="shrink-0 select-none font-mono text-foreground">
671
680
  +
672
681
  </span>
673
- <span className="sr-only">After: </span>
682
+ <span className="sr-only">{t("ui.changeReview.afterPrefix")}</span>
674
683
  <div className="min-w-0 break-words text-foreground">{after}</div>
675
684
  </div>
676
685
  )}
@@ -691,7 +700,7 @@ export const ChangeReviewHunk = forwardRef<HTMLLIElement, ChangeReviewHunkProps>
691
700
  {/* Status indicator: NON-color cue via symbol + color together */}
692
701
  <span
693
702
  role="img"
694
- aria-label={`Change type: ${statusLabel}`}
703
+ aria-label={t("ui.changeReview.changeType", { status: statusLabel })}
695
704
  className="shrink-0 inline-flex items-center justify-center size-5"
696
705
  >
697
706
  {HUNK_STATUS_ICONS[status]}
@@ -732,7 +741,7 @@ export const ChangeReviewHunk = forwardRef<HTMLLIElement, ChangeReviewHunkProps>
732
741
  <div className="flex items-center gap-1.5">
733
742
  <Badge variant="success" className="animate-in fade-in-0 duration-fast ease-entrance">
734
743
  <Check aria-hidden="true" className="size-3" />
735
- Approved
744
+ {t("ui.changeReview.approvedBadge")}
736
745
  </Badge>
737
746
  </div>
738
747
  )}
@@ -10,6 +10,7 @@ export const Checkbox = forwardRef<
10
10
  return (
11
11
  <CheckboxPrimitive.Root
12
12
  ref={ref}
13
+ data-slot="checkbox"
13
14
  className={cn(
14
15
  "peer size-4 shrink-0 rounded-sm border border-input shadow-sm transition-colors duration-fast ease-standard",
15
16
  "focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
@@ -25,9 +25,6 @@
25
25
  * state-keyed defaults for a generic panel. Sidebar passes its
26
26
  * `--sidebar-width` + `group-data-[collapsible=offcanvas]` literals and gets
27
27
  * back its exact original fragments (the byte-identical re-point).
28
- *
29
- * Fork-prevention: a second gap-spacer + fixed-slide width tween outside this
30
- * folder fails `pnpm collapse-fork:check` (scripts/check-collapse-fork.mjs).
31
28
  */
32
29
  import { useCallback, useMemo, useState } from "react";
33
30
  import { cn } from "../../lib/cn";
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  // a2ui.exposed: yes
2
4
  import {
3
5
  forwardRef,
@@ -10,6 +12,7 @@ import {
10
12
  } from "react";
11
13
  import { cn } from "../../lib/cn";
12
14
  import { Popover, PopoverContent, PopoverTrigger } from "../popover";
15
+ import { useLocale } from "../locale-provider";
13
16
 
14
17
  // ---------------------------------------------------------------------------
15
18
  // Token vocabulary
@@ -194,12 +197,14 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
194
197
  onValueChange,
195
198
  swatches = COLOR_TOKENS,
196
199
  allowCustom = false,
197
- "aria-label": ariaLabel = "Pick color",
200
+ "aria-label": ariaLabel,
198
201
  className,
199
202
  ...props
200
203
  },
201
204
  ref,
202
205
  ) {
206
+ const { t } = useLocale();
207
+ const resolvedAriaLabel = ariaLabel ?? t("ui.colorPicker.pickColor");
203
208
  const isControlled = valueProp !== undefined;
204
209
  const [internalValue, setInternalValue] = useState<ColorToken | string>(
205
210
  defaultValue ?? "chart-1",
@@ -259,11 +264,11 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
259
264
  <PopoverTrigger asChild>
260
265
  <button
261
266
  type="button"
262
- aria-label={ariaLabel}
267
+ aria-label={resolvedAriaLabel}
263
268
  aria-haspopup="dialog"
264
269
  aria-expanded={open}
265
270
  className={cn(
266
- "inline-flex items-center gap-2 rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm",
271
+ "inline-flex items-center gap-2 rounded-md border border-input bg-background px-3 py-1.5 text-body shadow-sm",
267
272
  "transition-[color,box-shadow] duration-fast",
268
273
  "hover:bg-accent hover:text-accent-foreground",
269
274
  "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
@@ -271,7 +276,7 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
271
276
  )}
272
277
  >
273
278
  <TriggerSwatch value={current} />
274
- <span className="text-muted-foreground">{current || "None"}</span>
279
+ <span className="text-muted-foreground">{current || t("ui.colorPicker.none")}</span>
275
280
  </button>
276
281
  </PopoverTrigger>
277
282
 
@@ -279,7 +284,7 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
279
284
  <div
280
285
  ref={groupRef}
281
286
  role="radiogroup"
282
- aria-label="Color swatches"
287
+ aria-label={t("ui.colorPicker.swatches")}
283
288
  className="flex flex-wrap gap-1.5"
284
289
  onKeyDown={handleGroupKeyDown}
285
290
  >
@@ -296,18 +301,19 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
296
301
  {allowCustom && (
297
302
  <div className="mt-3 border-t border-border pt-3">
298
303
  {/* Custom hex is NOT theme-aware — it will not adapt to theme changes. */}
299
- <p className="mb-1.5 text-xs text-muted-foreground">
304
+ <p className="mb-1.5 text-meta text-muted-foreground">
300
305
  {/* #124: inline running text — ink rung, not the 3:1 mark rung. */}
301
- Custom hex <span className="text-warning-text">(not theme-aware)</span>
306
+ {t("ui.colorPicker.customHexLabel")}{" "}
307
+ <span className="text-warning-text">{t("ui.colorPicker.notThemeAware")}</span>
302
308
  </p>
303
309
  <div className="flex gap-2">
304
310
  <input
305
311
  type="text"
306
312
  value={customHex}
307
- placeholder="#3b82f6"
313
+ placeholder="#3b82f6" // i18n-exempt: example hex value, not prose
308
314
  maxLength={7}
309
315
  spellCheck={false}
310
- aria-label="Custom hex color"
316
+ aria-label={t("ui.colorPicker.customHex")}
311
317
  onChange={(e) => setCustomHex(e.target.value)}
312
318
  onKeyDown={(e) => {
313
319
  if (e.key === "Enter") {
@@ -316,7 +322,7 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
316
322
  }
317
323
  }}
318
324
  className={cn(
319
- "h-8 w-28 rounded-md border border-input bg-background px-2 text-sm",
325
+ "h-8 w-28 rounded-md border border-input bg-background px-2 text-body",
320
326
  "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
321
327
  "placeholder:text-muted-foreground",
322
328
  )}
@@ -325,7 +331,7 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
325
331
  type="button"
326
332
  onClick={handleCustomSubmit}
327
333
  className={cn(
328
- "h-8 rounded-md border border-input bg-background px-2 text-sm",
334
+ "h-8 rounded-md border border-input bg-background px-2 text-body",
329
335
  "hover:bg-accent hover:text-accent-foreground",
330
336
  "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
331
337
  )}
@@ -5,6 +5,16 @@ const meta = {
5
5
  title: "Forms/Combobox",
6
6
  component: Combobox,
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 instead of letting it
10
+ // stretch the full autodocs page.
11
+ decorators: [
12
+ (Story) => (
13
+ <div className="w-full max-w-md">
14
+ <Story />
15
+ </div>
16
+ ),
17
+ ],
8
18
  argTypes: {
9
19
  options: {
10
20
  description: "Array of `{ label, value }` options.",
@@ -66,6 +66,60 @@ describe("Combobox", () => {
66
66
  );
67
67
  expect(screen.getByRole("combobox")).toHaveTextContent("Custom Value");
68
68
  });
69
+
70
+ it("does not hard-fix the trigger width — fills its container by default", () => {
71
+ render(<Combobox options={[{ label: "A", value: "a" }]} />);
72
+ expect(screen.getByRole("combobox").className).not.toMatch(/\bw-56\b/);
73
+ });
74
+ });
75
+
76
+ // jsdom doesn't implement Element.scrollIntoView, which cmdk calls internally
77
+ // to keep the auto-highlighted item visible — see the comment on the
78
+ // `allowCustomValue` describe below for the full explanation.
79
+ describe("Combobox (options interaction)", () => {
80
+ let originalScrollIntoView: typeof Element.prototype.scrollIntoView;
81
+
82
+ beforeEach(() => {
83
+ originalScrollIntoView = Element.prototype.scrollIntoView;
84
+ Element.prototype.scrollIntoView = vi.fn();
85
+ });
86
+
87
+ afterEach(() => {
88
+ Element.prototype.scrollIntoView = originalScrollIntoView;
89
+ });
90
+
91
+ // #reviewed 1.7 — a hand-rolled `onValueChange ? onValueChange(v) : setInternal(v)`
92
+ // never updates internal state once a listener is attached, so an
93
+ // uncontrolled Combobox with an onValueChange handler never shows its own
94
+ // selection on the trigger.
95
+ it("updates the trigger's own displayed value when uncontrolled even with an onValueChange listener", async () => {
96
+ const onValueChange = vi.fn();
97
+ render(<Combobox options={[{ label: "Alpha", value: "a" }]} onValueChange={onValueChange} />);
98
+ await userEvent.click(screen.getByRole("combobox"));
99
+ await userEvent.click(await screen.findByRole("option", { name: "Alpha" }));
100
+ expect(onValueChange).toHaveBeenCalledWith("a");
101
+ expect(screen.getByRole("combobox")).toHaveTextContent("Alpha");
102
+ });
103
+
104
+ // Duplicate labels used to collide because CommandItem's `value` (cmdk's
105
+ // identity/scoring key) was the LABEL, not the unique option value.
106
+ it("distinguishes options that share the same label", async () => {
107
+ const onValueChange = vi.fn();
108
+ render(
109
+ <Combobox
110
+ options={[
111
+ { label: "Production", value: "prod-us" },
112
+ { label: "Production", value: "prod-eu" },
113
+ ]}
114
+ onValueChange={onValueChange}
115
+ />,
116
+ );
117
+ await userEvent.click(screen.getByRole("combobox"));
118
+ const options = await screen.findAllByRole("option", { name: "Production" });
119
+ expect(options).toHaveLength(2);
120
+ await userEvent.click(options[1]!);
121
+ expect(onValueChange).toHaveBeenCalledWith("prod-eu");
122
+ });
69
123
  });
70
124
 
71
125
  // #359 — allowCustomValue. A nested describe because these tests TYPE into the