@eifi1/ui-kit 0.5.1 → 0.6.1
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/README.md +50 -1
- package/dist/components/amount-input.d.ts +7 -0
- package/dist/components/autocomplete.d.ts +103 -0
- package/dist/components/autocomplete.js +260 -0
- package/dist/components/autocomplete.js.map +1 -0
- package/dist/components/calculator.d.ts +7 -0
- package/dist/components/checkbox.d.ts +9 -0
- package/dist/components/checkbox.js +7 -2
- package/dist/components/checkbox.js.map +1 -1
- package/dist/components/chip.d.ts +3 -2
- package/dist/components/chip.js +14 -1
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.d.ts +100 -0
- package/dist/components/choice-card.js +170 -0
- package/dist/components/choice-card.js.map +1 -0
- package/dist/components/combobox-core.d.ts +76 -6
- package/dist/components/combobox-core.js +119 -49
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +12 -2
- package/dist/components/combobox.js +42 -17
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +91 -0
- package/dist/components/danger-confirm.js +181 -0
- package/dist/components/danger-confirm.js.map +1 -0
- package/dist/components/dialog-frame.d.ts +84 -0
- package/dist/components/dialog-frame.js +86 -0
- package/dist/components/dialog-frame.js.map +1 -0
- package/dist/components/disclosure.d.ts +108 -0
- package/dist/components/disclosure.js +127 -0
- package/dist/components/disclosure.js.map +1 -0
- package/dist/components/entity-combobox.d.ts +17 -3
- package/dist/components/entity-combobox.js +25 -5
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/file-button.d.ts +161 -0
- package/dist/components/file-button.js +243 -0
- package/dist/components/file-button.js.map +1 -0
- package/dist/components/file-dropzone.d.ts +72 -23
- package/dist/components/file-dropzone.js +219 -94
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/icon-picker.d.ts +72 -0
- package/dist/components/icon-picker.js +104 -0
- package/dist/components/icon-picker.js.map +1 -0
- package/dist/components/mini-calendar.d.ts +3 -0
- package/dist/components/mini-calendar.js +4 -3
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +8 -1
- package/dist/components/modal.js +4 -2
- package/dist/components/modal.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +16 -3
- package/dist/components/multi-entity-combobox.js +25 -5
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/number-field.d.ts +41 -1
- package/dist/components/number-field.js +42 -10
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +35 -2
- package/dist/components/number-input.js +35 -4
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +7 -0
- package/dist/components/search-field.d.ts +16 -0
- package/dist/components/search-field.js +29 -7
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/signature-pad.d.ts +43 -1
- package/dist/components/signature-pad.js +74 -2
- package/dist/components/signature-pad.js.map +1 -1
- package/dist/components/swatch-picker.d.ts +69 -0
- package/dist/components/swatch-picker.js +75 -0
- package/dist/components/swatch-picker.js.map +1 -0
- package/dist/components/switch.d.ts +9 -0
- package/dist/components/switch.js +7 -2
- package/dist/components/switch.js.map +1 -1
- package/dist/components/tile-radio.d.ts +50 -0
- package/dist/components/tile-radio.js +140 -0
- package/dist/components/tile-radio.js.map +1 -0
- package/dist/components/toggle-group.d.ts +27 -5
- package/dist/components/toggle-group.js +22 -15
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/ui.d.ts +150 -12
- package/dist/components/ui.js +196 -22
- package/dist/components/ui.js.map +1 -1
- package/dist/i18n/defaults.d.ts +7 -0
- package/dist/i18n/defaults.js +13 -2
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +38 -5
- package/dist/i18n/kit-labels.js +12 -4
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/index.d.ts +16 -7
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/table-text.d.ts +127 -0
- package/dist/lib/table-text.js +82 -0
- package/dist/lib/table-text.js.map +1 -0
- package/dist/rhf/form.d.ts +79 -0
- package/dist/rhf/form.js +143 -0
- package/dist/rhf/form.js.map +1 -0
- package/dist/rhf.d.ts +4 -0
- package/dist/rhf.js +3 -0
- package/dist/rhf.js.map +1 -0
- package/dist/shell/app-shell.js +3 -1
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/table-text.d.ts +1 -0
- package/dist/table-text.js +3 -0
- package/dist/table-text.js.map +1 -0
- package/package.json +14 -1
- package/src/components/autocomplete.tsx +429 -0
- package/src/components/checkbox.tsx +16 -0
- package/src/components/chip.tsx +24 -2
- package/src/components/choice-card.tsx +305 -0
- package/src/components/combobox-core.tsx +228 -58
- package/src/components/combobox.tsx +58 -21
- package/src/components/danger-confirm.tsx +286 -0
- package/src/components/dialog-frame.tsx +179 -0
- package/src/components/disclosure.tsx +259 -0
- package/src/components/entity-combobox.tsx +41 -6
- package/src/components/file-button.tsx +458 -0
- package/src/components/file-dropzone.tsx +323 -117
- package/src/components/icon-picker.tsx +181 -0
- package/src/components/mini-calendar.tsx +7 -3
- package/src/components/modal.tsx +10 -2
- package/src/components/multi-entity-combobox.tsx +40 -6
- package/src/components/number-field.tsx +86 -10
- package/src/components/number-input.tsx +79 -2
- package/src/components/search-field.tsx +49 -6
- package/src/components/signature-pad.tsx +112 -0
- package/src/components/swatch-picker.tsx +141 -0
- package/src/components/switch.tsx +16 -0
- package/src/components/tile-radio.tsx +228 -0
- package/src/components/toggle-group.tsx +54 -18
- package/src/components/ui.tsx +400 -24
- package/src/i18n/defaults.ts +12 -1
- package/src/i18n/kit-labels.tsx +45 -5
- package/src/index.ts +19 -0
- package/src/lib/table-text.ts +265 -0
- package/src/rhf/form.tsx +300 -0
- package/src/rhf.ts +9 -0
- package/src/shell/app-shell.tsx +3 -1
- package/src/table-text.ts +8 -0
|
@@ -1,9 +1,25 @@
|
|
|
1
|
-
import { useRef, useState } from "react";
|
|
1
|
+
import { useId, useRef, useState } from "react";
|
|
2
2
|
import type { ComponentPropsWithoutRef } from "react";
|
|
3
|
-
import { Upload } from "lucide-react";
|
|
4
|
-
import { Button } from "./ui";
|
|
3
|
+
import { Upload, X } from "lucide-react";
|
|
4
|
+
import { Button, IconButton } from "./ui";
|
|
5
5
|
import { cn } from "../lib/cn";
|
|
6
|
-
import {
|
|
6
|
+
import { useAnnounce } from "../hooks/use-announce";
|
|
7
|
+
import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels";
|
|
8
|
+
import { DEFAULT_FILE_PICKER_LABELS, screenFiles, summariseRejections } from "./file-button";
|
|
9
|
+
import type { FilePickerLabels, FileRejection } from "./file-button";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Where a refused file's message goes.
|
|
13
|
+
*
|
|
14
|
+
* - `"toast"` — a `sonner` toast (the 0.5 behaviour, kept as the default for a caller
|
|
15
|
+
* that handles nothing itself so no existing dropzone goes quiet on upgrade).
|
|
16
|
+
* - `"inline"` — under the drop target, in the danger colour, tied to it with
|
|
17
|
+
* `aria-describedby` (plus `data-invalid` for styling), and spoken through a live
|
|
18
|
+
* region. Cleared by the next accepted pick.
|
|
19
|
+
* - `"none"` — nothing visible; the caller shows it from `onReject` / `onInvalid`.
|
|
20
|
+
* Still spoken, so a host rendering it should not ALSO make it `role="alert"`.
|
|
21
|
+
*/
|
|
22
|
+
export type FileDropzoneRejectionFeedback = "toast" | "inline" | "none";
|
|
7
23
|
|
|
8
24
|
/**
|
|
9
25
|
* `onInvalid` is omitted from the `<div>` attributes and kept as this component's own.
|
|
@@ -12,30 +28,64 @@ import { useKitFileLabels } from "../i18n/kit-labels";
|
|
|
12
28
|
* writes. Everything else a `<div>` takes reaches the root — which carries
|
|
13
29
|
* `role="button"`, so that is also where an `aria-label` or an `aria-describedby`
|
|
14
30
|
* belongs.
|
|
31
|
+
*
|
|
32
|
+
* Two modes. SINGLE (the default, and all of 0.5): `file` + `onFileSelected`. MULTIPLE
|
|
33
|
+
* (`multiple`): `files` + `onFilesSelected`. They are optional rather than a union so
|
|
34
|
+
* `FileDropzoneProps` stays an interface a caller can extend; a single-mode pick also
|
|
35
|
+
* reaches `onFilesSelected` as a one-element array, so one handler can serve both.
|
|
15
36
|
*/
|
|
16
37
|
export interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">, "onInvalid"> {
|
|
17
|
-
|
|
18
|
-
|
|
38
|
+
/** Single mode: the chosen file, echoed with its size. */
|
|
39
|
+
file?: File | null;
|
|
40
|
+
/** Single mode: called with the accepted file. */
|
|
41
|
+
onFileSelected?: (file: File) => void;
|
|
42
|
+
/** Take several files per pick or drop. Without it a drop of several keeps the first. */
|
|
43
|
+
multiple?: boolean;
|
|
44
|
+
/** Multiple mode: the chosen files, listed in the zone. */
|
|
45
|
+
files?: readonly File[];
|
|
46
|
+
/** Every accepted file of one pick, in order. Never called with an empty array. */
|
|
47
|
+
onFilesSelected?: (files: File[]) => void;
|
|
48
|
+
/**
|
|
49
|
+
* Show a remove button inside the zone once something is chosen (kastlan asked for
|
|
50
|
+
* it: the only way to un-choose a file was to reload). Single mode: removes the file.
|
|
51
|
+
* Multiple mode: a "remove all" button. The host clears its own state here — the
|
|
52
|
+
* zone is controlled. Focus returns to the zone, and the removal is announced.
|
|
53
|
+
*/
|
|
54
|
+
onClear?: () => void;
|
|
55
|
+
/** Multiple mode: a remove button per listed file. */
|
|
56
|
+
onRemove?: (file: File, index: number) => void;
|
|
19
57
|
accept: string;
|
|
20
|
-
isValid: (file: File) => boolean;
|
|
21
|
-
invalidMessage: string;
|
|
22
58
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* the
|
|
59
|
+
* The caller's check. Optional since 0.6: without it the zone checks `accept` itself
|
|
60
|
+
* (a drop ignores the dialog's filter). With it, `accept` is NOT re-checked — every
|
|
61
|
+
* 0.5 caller's `isValid` is its own type check, and doubling it could refuse a file
|
|
62
|
+
* the caller accepts (a `.zip` the OS labelled `application/octet-stream`).
|
|
63
|
+
*/
|
|
64
|
+
isValid?: (file: File) => boolean;
|
|
65
|
+
/** The message for an `isValid` refusal. Defaults to `labels.rejectedInvalid`. */
|
|
66
|
+
invalidMessage?: string;
|
|
67
|
+
/** Bytes; larger files are refused. */
|
|
68
|
+
maxSize?: number;
|
|
69
|
+
/** Multiple mode: files one pick may add. Pass what is LEFT for a running cap. */
|
|
70
|
+
maxFiles?: number;
|
|
71
|
+
/**
|
|
72
|
+
* Called once per refused file. The 0.5 hook, kept as it was; {@link onReject}
|
|
73
|
+
* carries the reason and the translated message as well.
|
|
27
74
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* without a FileDropzone anywhere in it. Both current consumers happen to depend
|
|
34
|
-
* on sonner, which is why nothing broke and why the contradiction survived:
|
|
35
|
-
* `use-wizard.ts` had already answered the identical question the other way, in
|
|
36
|
-
* writing, two modules over.
|
|
75
|
+
* Its existence used to be what switched the toast off, and it still does (see
|
|
76
|
+
* {@link rejectionFeedback}). The toast is a dynamic `import("sonner")` on the
|
|
77
|
+
* failure path only: `sonner` is an OPTIONAL peer, and a static import here once
|
|
78
|
+
* broke `import { Button }` for every app without it (see
|
|
79
|
+
* `optional-peer-imports.test.tsx`).
|
|
37
80
|
*/
|
|
38
81
|
onInvalid?: (file: File) => void;
|
|
82
|
+
/** Every refused file of one pick, with its reason and message. */
|
|
83
|
+
onReject?: (rejections: FileRejection[]) => void;
|
|
84
|
+
/** See {@link FileDropzoneRejectionFeedback}. Defaults to `"toast"` when neither
|
|
85
|
+
* `onInvalid` nor `onReject` is given, `"none"` when one is. */
|
|
86
|
+
rejectionFeedback?: FileDropzoneRejectionFeedback;
|
|
87
|
+
/** Per-instance overrides of the `filePicker` label namespace. */
|
|
88
|
+
labels?: Partial<FilePickerLabels>;
|
|
39
89
|
/** The dropzone's accessible name, and the instruction shown on it. A caller's own
|
|
40
90
|
* `aria-label` wins over it — see the root element below. */
|
|
41
91
|
dropLabel: string;
|
|
@@ -46,136 +96,292 @@ export interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">,
|
|
|
46
96
|
className?: string;
|
|
47
97
|
}
|
|
48
98
|
|
|
49
|
-
/** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml)
|
|
50
|
-
*
|
|
51
|
-
* `
|
|
52
|
-
* `hint` describe what to drop.
|
|
99
|
+
/** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml) and
|
|
100
|
+
* kastlan's document forms. Refusals go to `onReject`/`onInvalid` and to
|
|
101
|
+
* `rejectionFeedback`; the chosen file(s) are echoed with their size, otherwise
|
|
102
|
+
* `emptyLabel` + `hint` describe what to drop. For a trigger with no drop area, see
|
|
103
|
+
* `FileButton`. */
|
|
53
104
|
export function FileDropzone({
|
|
54
105
|
file,
|
|
55
106
|
onFileSelected,
|
|
107
|
+
multiple,
|
|
108
|
+
files,
|
|
109
|
+
onFilesSelected,
|
|
110
|
+
onClear,
|
|
111
|
+
onRemove,
|
|
56
112
|
accept,
|
|
57
113
|
isValid,
|
|
58
114
|
invalidMessage,
|
|
115
|
+
maxSize,
|
|
116
|
+
maxFiles,
|
|
59
117
|
onInvalid,
|
|
118
|
+
onReject,
|
|
119
|
+
rejectionFeedback,
|
|
120
|
+
labels: labelsProp,
|
|
60
121
|
dropLabel,
|
|
61
122
|
browseLabel,
|
|
62
123
|
emptyLabel,
|
|
63
124
|
hint,
|
|
64
125
|
className,
|
|
65
126
|
"aria-label": ariaLabel,
|
|
127
|
+
"aria-describedby": describedByProp,
|
|
66
128
|
...rest
|
|
67
129
|
}: FileDropzoneProps) {
|
|
68
130
|
const [dragOver, setDragOver] = useState(false);
|
|
131
|
+
const [error, setError] = useState<string | null>(null);
|
|
69
132
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
133
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
134
|
+
const errorId = useId();
|
|
70
135
|
// The size through the kit's `file.size` label (the provider's locale by default).
|
|
71
136
|
// This was `${n / 1024} KB`: ASCII digits, an English unit, and a binary kilobyte
|
|
72
137
|
// mislabelled as a decimal one, all in one template literal.
|
|
73
138
|
const fileText = useKitFileLabels();
|
|
139
|
+
const labels = useKitLabels("filePicker", DEFAULT_FILE_PICKER_LABELS, labelsProp);
|
|
140
|
+
// Two regions, two urgencies: a pick or a removal is news, a refusal is a failure.
|
|
141
|
+
// Both are needed because nothing inside a `role="button"` is read out — its
|
|
142
|
+
// children are presentational — so the echoed file name never reaches a screen
|
|
143
|
+
// reader on its own.
|
|
144
|
+
const status = useAnnounce();
|
|
145
|
+
const alert = useAnnounce({ politeness: "assertive" });
|
|
146
|
+
const feedback: FileDropzoneRejectionFeedback =
|
|
147
|
+
rejectionFeedback ?? (onInvalid || onReject ? "none" : "toast");
|
|
74
148
|
|
|
75
|
-
const
|
|
76
|
-
if (!
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
149
|
+
const acceptFiles = (list: ArrayLike<File> | null | undefined) => {
|
|
150
|
+
if (!list || list.length === 0) return;
|
|
151
|
+
const all = Array.from(list);
|
|
152
|
+
const { accepted, rejected } = screenFiles(
|
|
153
|
+
multiple ? all : all.slice(0, 1),
|
|
154
|
+
{
|
|
155
|
+
accept: isValid ? undefined : accept,
|
|
156
|
+
maxSize,
|
|
157
|
+
maxFiles: multiple ? maxFiles : undefined,
|
|
158
|
+
isValid,
|
|
159
|
+
invalidMessage,
|
|
160
|
+
},
|
|
161
|
+
labels,
|
|
162
|
+
fileText.size,
|
|
163
|
+
);
|
|
164
|
+
if (accepted.length > 0) {
|
|
165
|
+
setError(null);
|
|
166
|
+
if (multiple) {
|
|
167
|
+
onFilesSelected?.(accepted);
|
|
80
168
|
} else {
|
|
169
|
+
onFileSelected?.(accepted[0]);
|
|
170
|
+
onFilesSelected?.(accepted);
|
|
171
|
+
}
|
|
172
|
+
status.announce(labels.selected(accepted.length, accepted[0].name));
|
|
173
|
+
}
|
|
174
|
+
if (rejected.length > 0) {
|
|
175
|
+
for (const r of rejected) onInvalid?.(r.file);
|
|
176
|
+
onReject?.(rejected);
|
|
177
|
+
const message = summariseRejections(rejected, labels);
|
|
178
|
+
if (feedback === "toast") {
|
|
81
179
|
// sonner is an optional peer: imported here, on the failure path only, so an
|
|
82
180
|
// app that never trips this never has to install it. Not awaited — nothing
|
|
83
181
|
// downstream depends on the toast having appeared, and the handlers that
|
|
84
|
-
// reach this are DOM events.
|
|
85
|
-
|
|
182
|
+
// reach this are DOM events. sonner's own toaster is a live region, so this
|
|
183
|
+
// path does not announce a second time.
|
|
184
|
+
void import("sonner").then(({ toast }) => toast.error(message));
|
|
185
|
+
} else {
|
|
186
|
+
if (feedback === "inline") setError(message);
|
|
187
|
+
alert.announce(message);
|
|
86
188
|
}
|
|
87
|
-
return;
|
|
88
189
|
}
|
|
89
|
-
onFileSelected(f);
|
|
90
190
|
};
|
|
91
191
|
|
|
192
|
+
// The remove buttons unmount with the file they removed, and focus would fall to
|
|
193
|
+
// <body>. The zone is where the next action (pick again) starts.
|
|
194
|
+
const afterRemoval = (message: string) => {
|
|
195
|
+
rootRef.current?.focus();
|
|
196
|
+
status.announce(message);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const chosen: readonly File[] = multiple ? (files ?? []) : file ? [file] : [];
|
|
200
|
+
const showError = feedback === "inline" && error !== null;
|
|
201
|
+
const describedBy = cn(describedByProp, showError && errorId) || undefined;
|
|
202
|
+
|
|
92
203
|
return (
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
e
|
|
100
|
-
if (e.dataTransfer.types.includes("Files")) setDragOver(true);
|
|
101
|
-
}}
|
|
102
|
-
onDragOver={(e) => {
|
|
103
|
-
e.preventDefault();
|
|
104
|
-
e.stopPropagation();
|
|
105
|
-
e.dataTransfer.dropEffect = "copy";
|
|
106
|
-
if (!dragOver && e.dataTransfer.types.includes("Files")) setDragOver(true);
|
|
107
|
-
}}
|
|
108
|
-
onDragLeave={(e) => {
|
|
109
|
-
e.preventDefault();
|
|
110
|
-
e.stopPropagation();
|
|
111
|
-
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
|
|
112
|
-
setDragOver(false);
|
|
113
|
-
}}
|
|
114
|
-
onDrop={(e) => {
|
|
115
|
-
e.preventDefault();
|
|
116
|
-
e.stopPropagation();
|
|
117
|
-
setDragOver(false);
|
|
118
|
-
acceptFile(e.dataTransfer.files?.[0]);
|
|
119
|
-
}}
|
|
120
|
-
onClick={() => fileInputRef.current?.click()}
|
|
121
|
-
onKeyDown={(e) => {
|
|
122
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
204
|
+
<>
|
|
205
|
+
<div
|
|
206
|
+
// `...rest` first: every handler below is the drop gesture itself, and the
|
|
207
|
+
// `role`/`tabIndex` pair is what makes this div operable by keyboard at all.
|
|
208
|
+
{...rest}
|
|
209
|
+
ref={rootRef}
|
|
210
|
+
onDragEnter={(e) => {
|
|
123
211
|
e.preventDefault();
|
|
124
|
-
fileInputRef.current?.click();
|
|
125
|
-
}
|
|
126
|
-
}}
|
|
127
|
-
role="button"
|
|
128
|
-
tabIndex={0}
|
|
129
|
-
// The DOM spelling wins over `dropLabel`, which stays the name for every caller
|
|
130
|
-
// that passes no `aria-label` — i.e. all of them today. Standardising on
|
|
131
|
-
// `aria-label` (audit §api-design) does not get to silently rename an existing
|
|
132
|
-
// required prop.
|
|
133
|
-
aria-label={ariaLabel ?? dropLabel}
|
|
134
|
-
className={cn(
|
|
135
|
-
// `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,
|
|
136
|
-
// which Tailwind implements as `position: absolute` — so without a positioned
|
|
137
|
-
// ancestor its containing block is the INITIAL one, and it is laid out at its
|
|
138
|
-
// own offset from the top of the document. Nothing looks wrong, because the
|
|
139
|
-
// input is 1x1 and clipped; what breaks is the page height. On a long page
|
|
140
|
-
// every escaped sr-only element extends `documentElement.scrollHeight` to its
|
|
141
|
-
// own offset, which produces a second, whole-document scrollbar alongside the
|
|
142
|
-
// app shell's own — one that scrolls past the end of the content into nothing.
|
|
143
|
-
// Measured on the showcase: body 900px, document 47,919px.
|
|
144
|
-
"relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors",
|
|
145
|
-
// Three states out of two border tokens: the target rests on the plain
|
|
146
|
-
// hairline, hover pulls it to `--border-strong`, and a live drag keeps that
|
|
147
|
-
// border and adds the `--bg-active` wash on top — so "let go here" still reads
|
|
148
|
-
// one step louder than "you are over it".
|
|
149
|
-
dragOver
|
|
150
|
-
? "border-[var(--border-strong)] bg-[var(--bg-active)]"
|
|
151
|
-
: "border-[var(--border)] hover:border-[var(--border-strong)]",
|
|
152
|
-
className,
|
|
153
|
-
)}
|
|
154
|
-
>
|
|
155
|
-
<Upload className="size-5 text-[var(--text-muted)]" />
|
|
156
|
-
<div className="text-sm text-[var(--text-secondary)]">
|
|
157
|
-
{file ? <span className="font-medium">{file.name}</span> : emptyLabel}
|
|
158
|
-
</div>
|
|
159
|
-
<div className="text-xs text-[var(--text-muted)]">
|
|
160
|
-
{file ? fileText.size(file.size) : hint}
|
|
161
|
-
</div>
|
|
162
|
-
<Button
|
|
163
|
-
type="button"
|
|
164
|
-
variant="secondary"
|
|
165
|
-
onClick={(e) => {
|
|
166
212
|
e.stopPropagation();
|
|
167
|
-
|
|
213
|
+
if (e.dataTransfer.types.includes("Files")) setDragOver(true);
|
|
168
214
|
}}
|
|
215
|
+
onDragOver={(e) => {
|
|
216
|
+
e.preventDefault();
|
|
217
|
+
e.stopPropagation();
|
|
218
|
+
e.dataTransfer.dropEffect = "copy";
|
|
219
|
+
if (!dragOver && e.dataTransfer.types.includes("Files")) setDragOver(true);
|
|
220
|
+
}}
|
|
221
|
+
onDragLeave={(e) => {
|
|
222
|
+
e.preventDefault();
|
|
223
|
+
e.stopPropagation();
|
|
224
|
+
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
|
|
225
|
+
setDragOver(false);
|
|
226
|
+
}}
|
|
227
|
+
onDrop={(e) => {
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
e.stopPropagation();
|
|
230
|
+
setDragOver(false);
|
|
231
|
+
acceptFiles(e.dataTransfer.files);
|
|
232
|
+
}}
|
|
233
|
+
onClick={() => fileInputRef.current?.click()}
|
|
234
|
+
onKeyDown={(e) => {
|
|
235
|
+
// Only the zone's OWN keys. A remove button inside it is a real button, and
|
|
236
|
+
// Enter on it bubbling up here would open the picker instead of removing.
|
|
237
|
+
if (e.target !== e.currentTarget) return;
|
|
238
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
fileInputRef.current?.click();
|
|
241
|
+
}
|
|
242
|
+
}}
|
|
243
|
+
role="button"
|
|
244
|
+
tabIndex={0}
|
|
245
|
+
// The DOM spelling wins over `dropLabel`, which stays the name for every caller
|
|
246
|
+
// that passes no `aria-label` — i.e. all of them today. Standardising on
|
|
247
|
+
// `aria-label` (audit §api-design) does not get to silently rename an existing
|
|
248
|
+
// required prop.
|
|
249
|
+
aria-label={ariaLabel ?? dropLabel}
|
|
250
|
+
// No `aria-invalid`: ARIA does not allow it on `role="button"`. The message is
|
|
251
|
+
// reached through the description instead, and spoken when it appears.
|
|
252
|
+
aria-describedby={describedBy}
|
|
253
|
+
data-invalid={showError || undefined}
|
|
254
|
+
className={cn(
|
|
255
|
+
// `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,
|
|
256
|
+
// which Tailwind implements as `position: absolute` — so without a positioned
|
|
257
|
+
// ancestor its containing block is the INITIAL one, and it is laid out at its
|
|
258
|
+
// own offset from the top of the document. Nothing looks wrong, because the
|
|
259
|
+
// input is 1x1 and clipped; what breaks is the page height. On a long page
|
|
260
|
+
// every escaped sr-only element extends `documentElement.scrollHeight` to its
|
|
261
|
+
// own offset, which produces a second, whole-document scrollbar alongside the
|
|
262
|
+
// app shell's own — one that scrolls past the end of the content into nothing.
|
|
263
|
+
// Measured on the showcase: body 900px, document 47,919px.
|
|
264
|
+
"relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors",
|
|
265
|
+
// Three states out of two border tokens: the target rests on the plain
|
|
266
|
+
// hairline, hover pulls it to `--border-strong`, and a live drag keeps that
|
|
267
|
+
// border and adds the `--bg-active` wash on top — so "let go here" still reads
|
|
268
|
+
// one step louder than "you are over it". A refusal shown inline takes the
|
|
269
|
+
// danger border until the next good pick.
|
|
270
|
+
dragOver
|
|
271
|
+
? "border-[var(--border-strong)] bg-[var(--bg-active)]"
|
|
272
|
+
: showError
|
|
273
|
+
? "border-[var(--danger-border)]"
|
|
274
|
+
: "border-[var(--border)] hover:border-[var(--border-strong)]",
|
|
275
|
+
className,
|
|
276
|
+
)}
|
|
169
277
|
>
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
278
|
+
<Upload aria-hidden className="size-5 text-[var(--text-muted)]" />
|
|
279
|
+
{multiple && chosen.length > 0 ? (
|
|
280
|
+
<ul className="flex w-full max-w-sm flex-col gap-1 text-start">
|
|
281
|
+
{chosen.map((f, i) => (
|
|
282
|
+
<li
|
|
283
|
+
// Name + size + position: two files may share a name, and the index
|
|
284
|
+
// alone would re-key every row below a removal.
|
|
285
|
+
key={`${f.name}-${f.size}-${i}`}
|
|
286
|
+
className="flex items-center gap-2 text-sm text-[var(--text-secondary)]"
|
|
287
|
+
>
|
|
288
|
+
<span className="min-w-0 flex-1 truncate font-medium">{f.name}</span>
|
|
289
|
+
<span className="shrink-0 text-xs text-[var(--text-muted)]">{fileText.size(f.size)}</span>
|
|
290
|
+
{onRemove && (
|
|
291
|
+
<IconButton
|
|
292
|
+
type="button"
|
|
293
|
+
size="sm"
|
|
294
|
+
aria-label={labels.remove(f.name)}
|
|
295
|
+
onClick={(e) => {
|
|
296
|
+
e.stopPropagation();
|
|
297
|
+
onRemove(f, i);
|
|
298
|
+
afterRemoval(labels.removed(f.name));
|
|
299
|
+
}}
|
|
300
|
+
>
|
|
301
|
+
<X aria-hidden />
|
|
302
|
+
</IconButton>
|
|
303
|
+
)}
|
|
304
|
+
</li>
|
|
305
|
+
))}
|
|
306
|
+
</ul>
|
|
307
|
+
) : (
|
|
308
|
+
<>
|
|
309
|
+
<div className="flex items-center gap-1 text-sm text-[var(--text-secondary)]">
|
|
310
|
+
{file ? <span className="font-medium">{file.name}</span> : emptyLabel}
|
|
311
|
+
{!multiple && file && onClear && (
|
|
312
|
+
<IconButton
|
|
313
|
+
type="button"
|
|
314
|
+
size="sm"
|
|
315
|
+
aria-label={labels.remove(file.name)}
|
|
316
|
+
onClick={(e) => {
|
|
317
|
+
e.stopPropagation();
|
|
318
|
+
onClear();
|
|
319
|
+
afterRemoval(labels.removed(file.name));
|
|
320
|
+
}}
|
|
321
|
+
>
|
|
322
|
+
<X aria-hidden />
|
|
323
|
+
</IconButton>
|
|
324
|
+
)}
|
|
325
|
+
</div>
|
|
326
|
+
<div className="text-xs text-[var(--text-muted)]">
|
|
327
|
+
{file && !multiple ? fileText.size(file.size) : hint}
|
|
328
|
+
</div>
|
|
329
|
+
</>
|
|
330
|
+
)}
|
|
331
|
+
<div className="flex flex-wrap items-center justify-center gap-2">
|
|
332
|
+
<Button
|
|
333
|
+
type="button"
|
|
334
|
+
variant="secondary"
|
|
335
|
+
onClick={(e) => {
|
|
336
|
+
e.stopPropagation();
|
|
337
|
+
fileInputRef.current?.click();
|
|
338
|
+
}}
|
|
339
|
+
>
|
|
340
|
+
{browseLabel}
|
|
341
|
+
</Button>
|
|
342
|
+
{multiple && chosen.length > 0 && onClear && (
|
|
343
|
+
<Button
|
|
344
|
+
type="button"
|
|
345
|
+
variant="ghost"
|
|
346
|
+
onClick={(e) => {
|
|
347
|
+
e.stopPropagation();
|
|
348
|
+
onClear();
|
|
349
|
+
afterRemoval(labels.cleared);
|
|
350
|
+
}}
|
|
351
|
+
>
|
|
352
|
+
{labels.clearAll}
|
|
353
|
+
</Button>
|
|
354
|
+
)}
|
|
355
|
+
</div>
|
|
356
|
+
{showError && (
|
|
357
|
+
<p id={errorId} className="text-xs text-[var(--danger)]">
|
|
358
|
+
{error}
|
|
359
|
+
</p>
|
|
360
|
+
)}
|
|
361
|
+
<input
|
|
362
|
+
ref={fileInputRef}
|
|
363
|
+
type="file"
|
|
364
|
+
accept={accept}
|
|
365
|
+
multiple={multiple}
|
|
366
|
+
onChange={(e) => {
|
|
367
|
+
// Copy, THEN reset: `files` is a live view of the value, and without the
|
|
368
|
+
// reset picking the same file a second time fires no `change` at all —
|
|
369
|
+
// the retry after a failed upload silently did nothing (kastlan).
|
|
370
|
+
const picked = Array.from(e.currentTarget.files ?? []);
|
|
371
|
+
e.currentTarget.value = "";
|
|
372
|
+
acceptFiles(picked);
|
|
373
|
+
}}
|
|
374
|
+
// Clicks are forwarded here programmatically; one reaching the zone's own
|
|
375
|
+
// handler again would open the dialog a second time.
|
|
376
|
+
onClick={(e) => e.stopPropagation()}
|
|
377
|
+
className="sr-only"
|
|
378
|
+
/>
|
|
379
|
+
</div>
|
|
380
|
+
{/* Outside the zone: a live region inside a `role="button"` is inside
|
|
381
|
+
presentational content, and screen readers are not consistent about
|
|
382
|
+
reading those. `sr-only-fixed`, so they need no positioned ancestor. */}
|
|
383
|
+
<span {...status.regionProps} />
|
|
384
|
+
<span {...alert.regionProps} />
|
|
385
|
+
</>
|
|
180
386
|
);
|
|
181
387
|
}
|