@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.
- package/dist/{chunk-JKPPTL63.js → chunk-FCH4ZN6G.js} +12 -3
- package/dist/chunk-FCH4ZN6G.js.map +1 -0
- package/dist/form.js +3 -3
- package/dist/form.js.map +1 -1
- package/dist/index.d.ts +126 -35
- package/dist/index.js +3346 -2079
- package/dist/index.js.map +1 -1
- package/dist/lib/cn.js +10 -1
- package/dist/lib/cn.js.map +1 -1
- package/package.json +3 -3
- package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
- package/src/__contract__/badge.contract.test.tsx +49 -0
- package/src/__contract__/bounded-number.contract.test.tsx +49 -0
- package/src/__contract__/button.contract.test.tsx +49 -0
- package/src/__contract__/color-picker.contract.test.tsx +49 -0
- package/src/__contract__/combobox.contract.test.tsx +49 -0
- package/src/__contract__/command-trigger.contract.test.tsx +49 -0
- package/src/__contract__/copyable-value.contract.test.tsx +49 -0
- package/src/__contract__/date-picker.contract.test.tsx +49 -0
- package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
- package/src/__contract__/empty-state.contract.test.tsx +49 -0
- package/src/__contract__/error-state.contract.test.tsx +49 -0
- package/src/__contract__/icon-button.contract.test.tsx +49 -0
- package/src/__contract__/input.contract.test.tsx +49 -0
- package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
- package/src/__contract__/list-editor.contract.test.tsx +49 -0
- package/src/__contract__/loading-state.contract.test.tsx +49 -0
- package/src/__contract__/metric-card.contract.test.tsx +49 -0
- package/src/__contract__/model-picker.contract.test.tsx +49 -0
- package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
- package/src/__contract__/number-input.contract.test.tsx +49 -0
- package/src/__contract__/rating.contract.test.tsx +49 -0
- package/src/__contract__/schema-form.contract.test.tsx +49 -0
- package/src/__contract__/section-header.contract.test.tsx +49 -0
- package/src/__contract__/segmented-field.contract.test.tsx +49 -0
- package/src/__contract__/slider-number.contract.test.tsx +49 -0
- package/src/__contract__/status-badge.contract.test.tsx +49 -0
- package/src/__contract__/tag-input.contract.test.tsx +49 -0
- package/src/__contract__/text.contract.test.tsx +49 -0
- package/src/__contract__/textarea.contract.test.tsx +49 -0
- package/src/__contract__/timeline.contract.test.tsx +49 -0
- package/src/__contract__/toggle.contract.test.tsx +49 -0
- package/src/__contract__/transfer.contract.test.tsx +49 -0
- package/src/__contract__/tree-select.contract.test.tsx +49 -0
- package/src/__contract__/tree.contract.test.tsx +49 -0
- package/src/__contract__/virtual-select.contract.test.tsx +49 -0
- package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
- package/src/components/accordion/accordion.tsx +13 -4
- package/src/components/alert/alert.tsx +11 -3
- package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
- package/src/components/alert-dialog/alert-dialog.tsx +28 -6
- package/src/components/app-shell/app-shell.test.tsx +99 -0
- package/src/components/app-shell/app-shell.tsx +29 -10
- package/src/components/attribution-panel/attribution-panel.tsx +2 -0
- package/src/components/attribution-panel/attributions.generated.ts +2 -2
- package/src/components/avatar/avatar.tsx +4 -1
- package/src/components/badge/badge.test.tsx +8 -0
- package/src/components/badge/badge.tsx +14 -4
- package/src/components/bento-grid/bento-grid.test.tsx +23 -2
- package/src/components/bento-grid/bento-grid.tsx +10 -0
- package/src/components/bounded-number/bounded-number.test.tsx +1 -1
- package/src/components/bounded-number/bounded-number.tsx +78 -75
- package/src/components/breadcrumb/breadcrumb.tsx +3 -2
- package/src/components/button/button.tsx +18 -12
- package/src/components/button-group/button-group.tsx +1 -1
- package/src/components/calendar/calendar.tsx +3 -2
- package/src/components/card/card.test.tsx +24 -2
- package/src/components/card/card.tsx +47 -6
- package/src/components/carousel/carousel.test.tsx +169 -0
- package/src/components/carousel/carousel.tsx +93 -32
- package/src/components/change-review/change-review.tsx +38 -29
- package/src/components/checkbox/checkbox.tsx +1 -0
- package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
- package/src/components/color-picker/color-picker.tsx +17 -11
- package/src/components/combobox/combobox.stories.tsx +10 -0
- package/src/components/combobox/combobox.test.tsx +54 -0
- package/src/components/combobox/combobox.tsx +25 -11
- package/src/components/command/command.test.tsx +15 -0
- package/src/components/command/command.tsx +8 -6
- package/src/components/context-menu/context-menu.tsx +16 -7
- package/src/components/context-rail/context-rail.tsx +4 -4
- package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
- package/src/components/copyable-value/copyable-value.test.tsx +28 -1
- package/src/components/copyable-value/copyable-value.tsx +12 -1
- package/src/components/date-picker/date-picker.stories.tsx +9 -0
- package/src/components/date-picker/date-picker.test.tsx +52 -0
- package/src/components/date-picker/date-picker.tsx +13 -15
- package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
- package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
- package/src/components/date-range-picker/date-range-picker.tsx +39 -28
- package/src/components/descriptions/descriptions.tsx +1 -1
- package/src/components/dialog/dialog.test.tsx +8 -3
- package/src/components/dialog/dialog.tsx +12 -4
- package/src/components/drawer/drawer.tsx +23 -6
- package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
- package/src/components/field/field.tsx +2 -0
- package/src/components/field-row/field-row.tsx +2 -0
- package/src/components/file-upload/file-upload.test.tsx +83 -10
- package/src/components/file-upload/file-upload.tsx +150 -28
- package/src/components/form/form.tsx +4 -2
- package/src/components/hover-card/hover-card.tsx +1 -0
- package/src/components/icon-button/icon-button.tsx +1 -1
- package/src/components/input/input.tsx +7 -3
- package/src/components/input-group/input-group.tsx +6 -6
- package/src/components/input-otp/input-otp.tsx +3 -1
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +2 -2
- package/src/components/label/label.tsx +1 -1
- package/src/components/list-editor/list-editor.test.tsx +24 -0
- package/src/components/list-editor/list-editor.tsx +126 -52
- package/src/components/locale-provider/locale-keys.test.ts +134 -0
- package/src/components/locale-provider/messages.ts +321 -0
- package/src/components/mention-input/mention-input.stories.tsx +3 -3
- package/src/components/mention-input/mention-input.test.tsx +51 -1
- package/src/components/mention-input/mention-input.tsx +58 -8
- package/src/components/menubar/menubar.tsx +16 -6
- package/src/components/nav-main/nav-main.tsx +7 -3
- package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
- package/src/components/nav-notifications/nav-notifications.tsx +11 -5
- package/src/components/nav-user/nav-user.tsx +5 -5
- package/src/components/navigation-menu/navigation-menu.tsx +16 -4
- package/src/components/number-input/number-input.stories.tsx +7 -3
- package/src/components/number-input/number-input.test.tsx +47 -0
- package/src/components/number-input/number-input.tsx +65 -24
- package/src/components/pagination/pagination.test.tsx +20 -1
- package/src/components/pagination/pagination.tsx +18 -14
- package/src/components/popover/popover.tsx +1 -0
- package/src/components/progress/progress.tsx +2 -0
- package/src/components/rating/rating.tsx +2 -0
- package/src/components/resizable/resizable.test.tsx +76 -0
- package/src/components/resizable/resizable.tsx +31 -5
- package/src/components/revision-timeline/revision-timeline.tsx +7 -2
- package/src/components/schema-form/schema-form-store.ts +506 -0
- package/src/components/schema-form/schema-form.test.tsx +122 -1
- package/src/components/schema-form/schema-form.tsx +163 -327
- package/src/components/section-header/section-header.tsx +2 -2
- package/src/components/select/select.tsx +7 -5
- package/src/components/separator/separator.tsx +1 -0
- package/src/components/sheet/sheet.test.tsx +39 -0
- package/src/components/sheet/sheet.tsx +40 -9
- package/src/components/side-dock/side-dock.test.tsx +26 -0
- package/src/components/side-dock/side-dock.tsx +15 -2
- package/src/components/sidebar/sidebar.test.tsx +35 -1
- package/src/components/sidebar/sidebar.tsx +20 -6
- package/src/components/skeleton/skeleton.tsx +1 -0
- package/src/components/slider-number/slider-number.stories.tsx +4 -1
- package/src/components/slider-number/slider-number.test.tsx +1 -1
- package/src/components/sonner/sonner.tsx +2 -0
- package/src/components/spinner/spinner.test.tsx +18 -0
- package/src/components/spinner/spinner.tsx +14 -4
- package/src/components/state-panel/state-panel.test.tsx +3 -3
- package/src/components/state-panel/state-panel.tsx +15 -8
- package/src/components/switch/switch.tsx +2 -0
- package/src/components/table/table.stories.tsx +3 -1
- package/src/components/table/table.test.tsx +9 -2
- package/src/components/table/table.tsx +18 -8
- package/src/components/tabs/index.ts +10 -1
- package/src/components/tabs/tabs.stories.tsx +48 -0
- package/src/components/tabs/tabs.test.tsx +67 -0
- package/src/components/tabs/tabs.tsx +175 -77
- package/src/components/tag-input/tag-input.tsx +4 -2
- package/src/components/team-switcher/team-switcher.tsx +3 -3
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
- package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
- package/src/components/theme-switcher/theme-switcher.tsx +134 -10
- package/src/components/timeline/timeline.tsx +0 -3
- package/src/components/toggle/toggle.tsx +7 -2
- package/src/components/toggle-group/toggle-group.tsx +2 -0
- package/src/components/tooltip/tooltip.tsx +2 -1
- package/src/components/top-nav/top-nav.tsx +1 -1
- package/src/components/transfer/transfer.tsx +35 -30
- package/src/components/tree/tree.test.tsx +41 -0
- package/src/components/tree/tree.tsx +77 -22
- package/src/components/tree/use-tree-keyboard.ts +37 -14
- package/src/components/tree-select/tree-select.stories.tsx +9 -0
- package/src/components/tree-select/tree-select.test.tsx +5 -0
- package/src/components/tree-select/tree-select.tsx +11 -14
- package/src/components/typography/prose.test.tsx +3 -2
- package/src/components/typography/prose.tsx +3 -3
- package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
- package/src/components/virtual-select/virtual-select.test.tsx +28 -2
- package/src/components/virtual-select/virtual-select.tsx +56 -18
- package/src/components/wizard/wizard.test.tsx +9 -0
- package/src/components/wizard/wizard.tsx +12 -4
- package/src/index.ts +5 -0
- package/src/lib/cn.ts +9 -0
- package/src/lib/csv.ts +48 -0
- package/src/lib/diff-rows.ts +2 -0
- package/src/lib/merge-refs.ts +2 -0
- package/src/lib/use-controllable-state.ts +47 -0
- package/src/lib/use-mobile.test.tsx +47 -0
- package/src/lib/use-mobile.ts +26 -10
- 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
|
|
89
|
-
searchPlaceholder
|
|
90
|
-
emptyText
|
|
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(() =>
|
|
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
|
-
|
|
201
|
-
|
|
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">{
|
|
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-
|
|
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={
|
|
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={
|
|
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
|
-
|
|
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-
|
|
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-
|
|
390
|
+
className="px-2 py-6 text-center text-body text-muted-foreground"
|
|
353
391
|
>
|
|
354
|
-
{
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lib/diff-rows.ts
CHANGED
package/src/lib/merge-refs.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/lib/use-mobile.ts
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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"]}
|