@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
@@ -19,6 +19,7 @@ import { Badge } from "../badge";
19
19
  import { Button } from "../button";
20
20
  import { Input } from "../input";
21
21
  import { Popover, PopoverContent, PopoverTrigger } from "../popover";
22
+ import { useLocale } from "../locale-provider";
22
23
  import { type VirtualSelectOption, useVirtualListbox } from "./use-virtual-listbox";
23
24
 
24
25
  export type { VirtualSelectOption };
@@ -85,9 +86,9 @@ export const VirtualSelect = forwardRef<HTMLButtonElement, VirtualSelectProps>(
85
86
  value,
86
87
  defaultValue,
87
88
  onValueChange,
88
- placeholder = "Select…",
89
- searchPlaceholder = "Search…",
90
- emptyText = "No results.",
89
+ placeholder,
90
+ searchPlaceholder,
91
+ emptyText,
91
92
  estimateOptionHeight = 32,
92
93
  maxListHeight = "18rem",
93
94
  className,
@@ -95,6 +96,11 @@ export const VirtualSelect = forwardRef<HTMLButtonElement, VirtualSelectProps>(
95
96
  },
96
97
  ref,
97
98
  ) {
99
+ const { t } = useLocale();
100
+ const resolvedPlaceholder = placeholder ?? t("ui.virtualSelect.placeholder");
101
+ const resolvedSearchPlaceholder = searchPlaceholder ?? t("ui.virtualSelect.searchPlaceholder");
102
+ const resolvedEmptyText = emptyText ?? t("noResults");
103
+
98
104
  // ------------------------------------------------------------------
99
105
  // Controlled / uncontrolled
100
106
  // ------------------------------------------------------------------
@@ -135,6 +141,15 @@ export const VirtualSelect = forwardRef<HTMLButtonElement, VirtualSelectProps>(
135
141
  [options, query],
136
142
  );
137
143
 
144
+ // O(1) label lookup for the trigger's selected chips — `selected.map(find)`
145
+ // is O(selected.length * options.length) and options lists here can be
146
+ // large (that's the whole point of virtualizing).
147
+ const optionByValue = useMemo(() => {
148
+ const map = new Map<string, VirtualSelectOption>();
149
+ for (const o of options) map.set(o.value, o);
150
+ return map;
151
+ }, [options]);
152
+
138
153
  // ------------------------------------------------------------------
139
154
  // Virtualizer
140
155
  // ------------------------------------------------------------------
@@ -184,26 +199,45 @@ export const VirtualSelect = forwardRef<HTMLButtonElement, VirtualSelectProps>(
184
199
  const listboxId = `${uid}-listbox`;
185
200
  const optionId = (index: number) => `${uid}-option-${index}`;
186
201
 
187
- // Focus the search input when the popover opens
202
+ // Focus the search input when the popover opens. The timeout id is
203
+ // tracked so it can be cancelled on unmount (or a rapid re-close) —
204
+ // otherwise it fires after the component (and `searchRef`'s DOM node)
205
+ // is gone, or focuses a NEW popover's input on a fast toggle.
206
+ const focusTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
207
+
208
+ useEffect(
209
+ () => () => {
210
+ if (focusTimeoutRef.current != null) clearTimeout(focusTimeoutRef.current);
211
+ },
212
+ [],
213
+ );
214
+
188
215
  const handleOpenChange = useCallback((next: boolean) => {
189
216
  setOpen(next);
217
+ if (focusTimeoutRef.current != null) {
218
+ clearTimeout(focusTimeoutRef.current);
219
+ focusTimeoutRef.current = null;
220
+ }
190
221
  if (next) {
191
222
  // Defer one frame so Radix has mounted the portal
192
- setTimeout(() => searchRef.current?.focus(), 0);
223
+ focusTimeoutRef.current = setTimeout(() => {
224
+ focusTimeoutRef.current = null;
225
+ searchRef.current?.focus();
226
+ }, 0);
193
227
  }
194
228
  }, []);
195
229
 
196
230
  // ------------------------------------------------------------------
197
231
  // Trigger label
198
232
  // ------------------------------------------------------------------
199
- const selectedLabels = selected.map((v) => {
200
- const opt = options.find((o) => o.value === v);
201
- return { value: v, label: opt?.label ?? v };
202
- });
233
+ const selectedLabels = selected.map((v) => ({
234
+ value: v,
235
+ label: optionByValue.get(v)?.label ?? v,
236
+ }));
203
237
 
204
238
  const triggerContent =
205
239
  selectedLabels.length === 0 ? (
206
- <span className="text-muted-foreground">{placeholder}</span>
240
+ <span className="text-muted-foreground">{resolvedPlaceholder}</span>
207
241
  ) : multiple ? (
208
242
  <>
209
243
  {selectedLabels.slice(0, 2).map((l) => (
@@ -247,7 +281,7 @@ export const VirtualSelect = forwardRef<HTMLButtonElement, VirtualSelectProps>(
247
281
  aria-expanded={open}
248
282
  aria-haspopup="listbox"
249
283
  aria-controls={listboxId}
250
- className={cn("h-auto min-h-9 w-64 justify-between font-normal", className)}
284
+ className={cn("h-auto min-h-9 w-full justify-between font-normal", className)}
251
285
  {...props}
252
286
  >
253
287
  <span className="flex min-w-0 flex-1 flex-wrap items-center gap-1 text-start">
@@ -258,7 +292,7 @@ export const VirtualSelect = forwardRef<HTMLButtonElement, VirtualSelectProps>(
258
292
  </PopoverTrigger>
259
293
 
260
294
  <PopoverContent
261
- className="w-64 p-0"
295
+ className="w-[var(--radix-popover-trigger-width)] min-w-64 p-0"
262
296
  align="start"
263
297
  // Prevent Radix from stealing focus so it can go to the input
264
298
  onOpenAutoFocus={(e) => e.preventDefault()}
@@ -270,17 +304,21 @@ export const VirtualSelect = forwardRef<HTMLButtonElement, VirtualSelectProps>(
270
304
  type="text"
271
305
  autoComplete="off"
272
306
  spellCheck={false}
273
- placeholder={searchPlaceholder}
307
+ placeholder={resolvedSearchPlaceholder}
274
308
  value={query}
275
309
  onChange={(e) => setQuery(e.target.value)}
276
310
  onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => onInputKeyDown(e)}
277
- aria-label={searchPlaceholder}
311
+ aria-label={resolvedSearchPlaceholder}
278
312
  aria-autocomplete="list"
279
313
  aria-controls={listboxId}
280
314
  aria-activedescendant={
281
315
  activeIndex >= 0 && activeMounted ? optionId(activeIndex) : undefined
282
316
  }
283
- className="h-8 border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
317
+ // Border/shadow removed (sits inside the popover's own bordered
318
+ // header strip) but the base `Input` focus-ring is kept — no
319
+ // compound ring lives elsewhere on this control, so silencing it
320
+ // would leave a re-focused search box with no visible indicator.
321
+ className="h-8 border-0 shadow-none"
284
322
  />
285
323
  </div>
286
324
 
@@ -318,7 +356,7 @@ export const VirtualSelect = forwardRef<HTMLButtonElement, VirtualSelectProps>(
318
356
  transform: `translateY(${virtualRow.start}px)`,
319
357
  }}
320
358
  className={cn(
321
- "flex cursor-pointer select-none items-center rounded-sm px-2 text-sm outline-none",
359
+ "flex cursor-pointer select-none items-center rounded-sm px-2 text-body outline-none",
322
360
  "transition-colors duration-fast motion-reduce:transition-none",
323
361
  isActive && "bg-accent text-accent-foreground",
324
362
  !isActive && "hover:bg-accent hover:text-accent-foreground",
@@ -349,9 +387,9 @@ export const VirtualSelect = forwardRef<HTMLButtonElement, VirtualSelectProps>(
349
387
  <div
350
388
  role="status"
351
389
  aria-live="polite"
352
- className="px-2 py-6 text-center text-sm text-muted-foreground"
390
+ className="px-2 py-6 text-center text-body text-muted-foreground"
353
391
  >
354
- {emptyText}
392
+ {resolvedEmptyText}
355
393
  </div>
356
394
  ) : null}
357
395
  </PopoverContent>
@@ -64,4 +64,13 @@ describe("Wizard", () => {
64
64
  await user.click(screen.getByRole("button", { name: "Next" }));
65
65
  await waitFor(() => expect(screen.getByRole("button", { name: "Finish" })).toBeInTheDocument());
66
66
  });
67
+
68
+ it("names the completed-step indicator button — it renders only an icon", async () => {
69
+ const user = userEvent.setup();
70
+ renderWizard();
71
+ await user.click(screen.getByRole("button", { name: "Next" }));
72
+ // The first step's indicator now shows a checkmark glyph with no text
73
+ // content — without an accessible name it would be an unlabelled button.
74
+ expect(screen.getByRole("button", { name: "Account — completed" })).toBeInTheDocument();
75
+ });
67
76
  });
@@ -232,8 +232,12 @@ export const WizardSteps = forwardRef<HTMLOListElement, WizardStepsProps>(functi
232
232
  type="button"
233
233
  disabled={!isClickable}
234
234
  onClick={() => goTo(index)}
235
+ // The completed state renders only a checkmark glyph (hidden
236
+ // from AT below) — with no text content, the button would
237
+ // otherwise have no accessible name at all.
238
+ aria-label={isComplete ? `${meta.title} — completed` : undefined}
235
239
  className={cn(
236
- "flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-medium tabular-nums",
240
+ "flex size-7 shrink-0 items-center justify-center rounded-full border text-meta font-medium tabular-nums",
237
241
  "focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
238
242
  isActive && "border-primary bg-primary text-primary-foreground",
239
243
  // #399 — the step NUMBER is text on a `/10` wash, not plate ink
@@ -248,14 +252,14 @@ export const WizardSteps = forwardRef<HTMLOListElement, WizardStepsProps>(functi
248
252
  <span className="flex min-w-0 flex-col">
249
253
  <span
250
254
  className={cn(
251
- "truncate text-sm font-medium",
255
+ "truncate text-body font-medium",
252
256
  isActive ? "text-foreground" : "text-muted-foreground",
253
257
  )}
254
258
  >
255
259
  {meta.title}
256
260
  </span>
257
261
  {meta.description && (
258
- <span className="truncate text-xs text-muted-foreground">{meta.description}</span>
262
+ <span className="truncate text-meta text-muted-foreground">{meta.description}</span>
259
263
  )}
260
264
  </span>
261
265
  </li>
@@ -309,7 +313,11 @@ export const WizardStep = forwardRef<HTMLDivElement, WizardStepProps>(function W
309
313
  role="group"
310
314
  aria-labelledby={`${baseId}-step-${step}`}
311
315
  tabIndex={-1}
312
- className={cn("min-w-0 flex-1 focus-visible:outline-none", className)}
316
+ // Programmatically focused on step change (`regionRef.current?.focus()`
317
+ // above) — `focus-ring` gives that a visible indicator instead of
318
+ // silently discarding the browser's default outline (mirrors
319
+ // `DialogBody`'s same tabIndex={-1}-container precedent).
320
+ className={cn("min-w-0 flex-1 focus-ring", className)}
313
321
  {...props}
314
322
  >
315
323
  {children}
package/src/index.ts CHANGED
@@ -21,6 +21,11 @@ export { useIsMobile } from "./lib/use-mobile";
21
21
  // Browser file-download mechanics — one home for the Blob/URL → <a download> dance
22
22
  // (shared by ConversationDownload, downloadCsv, and Gallery). SSR-guarded.
23
23
  export { downloadBlob, downloadUrl } from "./lib/download";
24
+ // Shared CSV (RFC 4180) value stringification + injection-guarded field
25
+ // quoting — one home for logic `@elabs-ai/components-data`'s `toCsv` and
26
+ // `@elabs-ai/components-charts`'s ChartFrame serializer both need (charts may
27
+ // not depend on data, so both build on this instead).
28
+ export { csvStringifyValue, csvQuoteField } from "./lib/csv";
24
29
  // Clipboard write + transient "copied" flag — one implementation for the
25
30
  // editor's copy button and `CopyableValue`, so the timing and the
26
31
  // "no clipboard in this context" answer are defined once.
package/src/lib/cn.ts CHANGED
@@ -17,6 +17,15 @@ const twMerge = extendTailwindMerge({
17
17
  "font-size": [{ text: TEXT_ROLES }],
18
18
  // `font-display` is the display-face seam (sibling of font-mono, #187).
19
19
  "font-family": [{ font: ["display"] }],
20
+ // Theme-driven control/table knobs (themes.css § CONTROL UTILITIES and the
21
+ // `@theme inline` bridge) — so a caller's `h-8` / `rounded-none` /
22
+ // `font-bold` / `shadow-none` still replaces the token default.
23
+ "font-weight": [{ font: ["control", "table-header"] }],
24
+ rounded: [{ rounded: ["control"] }],
25
+ shadow: [{ shadow: ["input"] }],
26
+ h: [{ h: ["control", "control-sm", "control-lg", "header"] }],
27
+ size: [{ size: ["control", "control-sm", "control-lg"] }],
28
+ "min-w": [{ "min-w": ["control", "control-sm", "control-lg"] }],
20
29
  },
21
30
  },
22
31
  });
package/src/lib/csv.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Shared CSV (RFC 4180) primitives — value stringification + injection-guarded
3
+ * field quoting. Pure, no DOM, SSR-safe.
4
+ *
5
+ * The single home for logic previously duplicated between `@elabs-ai/components-data`'s
6
+ * `toCsv` and `@elabs-ai/components-charts`'s `ChartFrame` local serializer (charts may not
7
+ * depend on data per the one-way package rule, so both import this instead of
8
+ * one importing the other).
9
+ */
10
+
11
+ /** Leading characters a spreadsheet may interpret as a formula trigger. */
12
+ const INJECTION_TRIGGER = /^[=+\-@]/;
13
+
14
+ /**
15
+ * True when `field` parses as a plain finite number, e.g. "-5" or "+12.3".
16
+ * A negative/positive number is not a formula — only guard fields that
17
+ * START with a trigger character AND are not actually numeric.
18
+ */
19
+ function isNumericLiteral(field: string): boolean {
20
+ const trimmed = field.trim();
21
+ if (trimmed === "") return false;
22
+ return Number.isFinite(Number(trimmed));
23
+ }
24
+
25
+ /** Stringify a cell value for CSV: dates → ISO, objects → JSON, else `String()`. */
26
+ export function csvStringifyValue(value: unknown): string {
27
+ if (value === null || value === undefined) return "";
28
+ if (value instanceof Date) return value.toISOString();
29
+ if (typeof value === "object") return JSON.stringify(value);
30
+ return String(value);
31
+ }
32
+
33
+ /**
34
+ * Quote/escape one CSV field: prefixes a leading `'` when the field starts
35
+ * with a formula-trigger character and is not a plain number (CSV-injection
36
+ * guard), then RFC-4180-quotes when it contains the delimiter, a double
37
+ * quote, or a line break.
38
+ */
39
+ export function csvQuoteField(field: string, delimiter: string): string {
40
+ let out = field;
41
+ if (INJECTION_TRIGGER.test(out) && !isNumericLiteral(out)) {
42
+ out = "'" + out;
43
+ }
44
+ if (out.includes(delimiter) || out.includes('"') || out.includes("\n") || out.includes("\r")) {
45
+ return '"' + out.replaceAll('"', '""') + '"';
46
+ }
47
+ return out;
48
+ }
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  /**
2
4
  * The context-run-collapsing hunk-windowing algorithm behind `DiffView`
3
5
  * (`@elabs-ai/components-ai`, #102, where it shipped as the private
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  import type { ForwardedRef } from "react";
2
4
 
3
5
  /**
@@ -0,0 +1,47 @@
1
+ "use client";
2
+
3
+ import { useCallback, useRef, useState } from "react";
4
+
5
+ /**
6
+ * Shared controlled/uncontrolled state primitive.
7
+ *
8
+ * `isControlled` is fixed by whether `controlledValue` is `undefined` on the
9
+ * FIRST render (the usual React contract — don't flip modes across renders).
10
+ * The setter always does BOTH of its jobs, unconditionally:
11
+ * - uncontrolled: updates the internal state (so the next render reflects it)
12
+ * - always: calls `onChange`, so a listener attached to an otherwise
13
+ * uncontrolled component still observes every change.
14
+ *
15
+ * A hand-rolled `value ?? internal` + `onChange ? onChange(v) : setInternal(v)`
16
+ * ternary gets this wrong in two ways: an uncontrolled component with an
17
+ * `onChange` handler stops updating its own displayed value (the ternary
18
+ * only ever takes one branch), and clearing a controlled value to `undefined`
19
+ * falls through to a stale `internal` instead of showing "no value" — see
20
+ * Combobox/DatePicker (#reviewed 1.7).
21
+ */
22
+ export function useControllableState<T>(
23
+ controlledValue: T | undefined,
24
+ defaultValue: T,
25
+ onChange?: (value: T) => void,
26
+ ): [T, (next: T) => void] {
27
+ // Locked on mount, not recomputed every render: once a caller passes a
28
+ // defined `value`, the component stays controlled even if a later render
29
+ // clears it to `undefined` (e.g. "no date selected"). Recomputing
30
+ // `value !== undefined` on every render would flip a cleared controlled
31
+ // value to the (stale) uncontrolled `internal` state instead of showing
32
+ // "empty" — see DateRangePicker (#reviewed 1.7).
33
+ const isControlledRef = useRef(controlledValue !== undefined);
34
+ const isControlled = isControlledRef.current;
35
+ const [internal, setInternal] = useState<T>(defaultValue);
36
+ const value = isControlled ? (controlledValue as T) : internal;
37
+
38
+ const setValue = useCallback(
39
+ (next: T) => {
40
+ if (!isControlled) setInternal(next);
41
+ onChange?.(next);
42
+ },
43
+ [isControlled, onChange],
44
+ );
45
+
46
+ return [value, setValue];
47
+ }
@@ -0,0 +1,47 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { useIsMobile } from "./use-mobile";
4
+
5
+ function setViewportWidth(width: number) {
6
+ Object.defineProperty(window, "innerWidth", {
7
+ writable: true,
8
+ configurable: true,
9
+ value: width,
10
+ });
11
+ }
12
+
13
+ describe("useIsMobile", () => {
14
+ const originalInnerWidth = window.innerWidth;
15
+
16
+ afterEach(() => {
17
+ setViewportWidth(originalInnerWidth);
18
+ });
19
+
20
+ it("reports the correct value on the very first render — no desktop flash", () => {
21
+ setViewportWidth(500);
22
+ const renderedValues: boolean[] = [];
23
+ function Probe() {
24
+ const isMobile = useIsMobile();
25
+ renderedValues.push(isMobile);
26
+ return <span>{String(isMobile)}</span>;
27
+ }
28
+ render(<Probe />);
29
+
30
+ // A stale `false` in the recorded renders means the mobile viewport
31
+ // still rendered the desktop layout for at least one paint before the
32
+ // effect corrected it — i.e. a flash.
33
+ expect(renderedValues.length).toBeGreaterThan(0);
34
+ expect(renderedValues.every((value) => value === true)).toBe(true);
35
+ expect(screen.getByText("true")).toBeInTheDocument();
36
+ });
37
+
38
+ it("reports false on a desktop-width viewport", () => {
39
+ setViewportWidth(1280);
40
+ function Probe() {
41
+ const isMobile = useIsMobile();
42
+ return <span>{String(isMobile)}</span>;
43
+ }
44
+ render(<Probe />);
45
+ expect(screen.getByText("false")).toBeInTheDocument();
46
+ });
47
+ });
@@ -1,7 +1,27 @@
1
- import { useEffect, useState } from "react";
1
+ "use client";
2
+
3
+ import { useSyncExternalStore } from "react";
2
4
 
3
5
  const MOBILE_BREAKPOINT = 768;
4
6
 
7
+ function subscribe(breakpoint: number, onStoreChange: () => void) {
8
+ const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
9
+ mql.addEventListener("change", onStoreChange);
10
+ return () => mql.removeEventListener("change", onStoreChange);
11
+ }
12
+
13
+ function getSnapshot(breakpoint: number) {
14
+ return window.innerWidth < breakpoint;
15
+ }
16
+
17
+ // SSR / first-paint snapshot: no viewport to measure yet, so report "not
18
+ // mobile" rather than guessing — this must match what the server rendered to
19
+ // avoid a hydration mismatch, and useSyncExternalStore re-syncs to the real
20
+ // value on the client before paint (no desktop-layout flash).
21
+ function getServerSnapshot() {
22
+ return false;
23
+ }
24
+
5
25
  /**
6
26
  * Returns true when the viewport is below `breakpoint` (default 768px, the
7
27
  * mobile breakpoint). Additive optional argument (ADR 0035 §5) — every
@@ -10,13 +30,9 @@ const MOBILE_BREAKPOINT = 768;
10
30
  * second hook.
11
31
  */
12
32
  export function useIsMobile(breakpoint: number = MOBILE_BREAKPOINT): boolean {
13
- const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
14
- useEffect(() => {
15
- const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
16
- const onChange = () => setIsMobile(window.innerWidth < breakpoint);
17
- mql.addEventListener("change", onChange);
18
- setIsMobile(window.innerWidth < breakpoint);
19
- return () => mql.removeEventListener("change", onChange);
20
- }, [breakpoint]);
21
- return !!isMobile;
33
+ return useSyncExternalStore(
34
+ (onStoreChange) => subscribe(breakpoint, onStoreChange),
35
+ () => getSnapshot(breakpoint),
36
+ getServerSnapshot,
37
+ );
22
38
  }
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/lib/cn.ts","../src/components/label/label.tsx"],"sourcesContent":["import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\n/**\n * The semantic type roles (`--text-<role>` tokens → `text-<role>` utilities, #187).\n * tailwind-merge doesn't know custom theme keys, so without registration it\n * classifies `text-title` as a text COLOR — and `cn(\"text-kpi\", \"text-foreground\")`\n * would silently drop the role. Registering the roles in the `font-size` class\n * group makes role-vs-role and role-vs-raw-size conflicts resolve correctly while\n * keeping role + color independent.\n */\nconst TEXT_ROLES = [\"display\", \"title\", \"subtitle\", \"body\", \"caption\", \"meta\", \"kpi\", \"code\"];\n\nconst twMerge = extendTailwindMerge({\n extend: {\n classGroups: {\n \"font-size\": [{ text: TEXT_ROLES }],\n // `font-display` is the display-face seam (sibling of font-mono, #187).\n \"font-family\": [{ font: [\"display\"] }],\n },\n },\n});\n\n/**\n * Merge class names with conflict resolution. `clsx` handles conditionals;\n * `tailwind-merge` ensures later Tailwind utilities win (e.g. `p-2 p-4` -> `p-4`).\n * Use this in every component that accepts a `className` prop.\n */\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n","import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from \"react\";\nimport * as LabelPrimitive from \"@radix-ui/react-label\";\nimport { cn } from \"../../lib/cn\";\n\nexport const Label = forwardRef<\n ElementRef<typeof LabelPrimitive.Root>,\n ComponentPropsWithoutRef<typeof LabelPrimitive.Root>\n>(function Label({ className, ...props }, ref) {\n return (\n <LabelPrimitive.Root\n ref={ref}\n className={cn(\n \"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70\",\n className,\n )}\n {...props}\n />\n );\n});\n"],"mappings":";;;;;;;;AAAA,SAAS,YAA6B;AACtC,SAAS,2BAA2B;AAUpC,IAAM,aAAa,CAAC,WAAW,SAAS,YAAY,QAAQ,WAAW,QAAQ,OAAO,MAAM;AAE5F,IAAM,UAAU,oBAAoB;AAAA,EAClC,QAAQ;AAAA,IACN,aAAa;AAAA,MACX,aAAa,CAAC,EAAE,MAAM,WAAW,CAAC;AAAA;AAAA,MAElC,eAAe,CAAC,EAAE,MAAM,CAAC,SAAS,EAAE,CAAC;AAAA,IACvC;AAAA,EACF;AACF,CAAC;AAOM,SAAS,MAAM,QAA8B;AAClD,SAAO,QAAQ,KAAK,MAAM,CAAC;AAC7B;;;AC9BA,SAAS,kBAAkE;AAC3E,YAAY,oBAAoB;AAQ5B;AALG,IAAM,QAAQ,WAGnB,SAASA,OAAM,EAAE,WAAW,GAAG,MAAM,GAAG,KAAK;AAC7C,SACE;AAAA,IAAgB;AAAA,IAAf;AAAA,MACC;AAAA,MACA,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MACF;AAAA,MACC,GAAG;AAAA;AAAA,EACN;AAEJ,CAAC;","names":["Label"]}