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