@eifi1/ui-kit 0.5.0 → 0.6.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/README.md +50 -1
- package/dist/components/amount-input.d.ts +7 -0
- package/dist/components/autocomplete.d.ts +101 -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/chip.d.ts +22 -7
- 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/currency-select.js +10 -2
- package/dist/components/currency-select.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/date-picker.js +19 -7
- package/dist/components/date-picker.js.map +1 -1
- 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 +225 -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/month-picker.js +16 -5
- package/dist/components/month-picker.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/popover.js +4 -1
- package/dist/components/popover.js.map +1 -1
- 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/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/trigger-aria.d.ts +18 -0
- package/dist/components/trigger-aria.js +25 -0
- package/dist/components/trigger-aria.js.map +1 -0
- package/dist/components/ui.d.ts +164 -18
- package/dist/components/ui.js +207 -34
- 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 +425 -0
- package/src/components/chip.tsx +43 -7
- 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/currency-select.tsx +16 -2
- package/src/components/danger-confirm.tsx +286 -0
- package/src/components/date-picker.tsx +31 -6
- 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 +431 -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/month-picker.tsx +22 -4
- 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/popover.tsx +4 -1
- 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/tile-radio.tsx +228 -0
- package/src/components/toggle-group.tsx +54 -18
- package/src/components/trigger-aria.ts +42 -0
- package/src/components/ui.tsx +427 -40
- 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
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { forwardRef, useRef, useState } from "react";
|
|
2
|
+
import type { ComponentPropsWithoutRef, DragEvent, ReactElement, ReactNode, Ref } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { useAnnounce } from "../hooks/use-announce";
|
|
5
|
+
import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels";
|
|
6
|
+
import { Button, Spinner } from "./ui";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A button that opens the file picker — the shape all three apps kept writing by hand
|
|
10
|
+
* as a `<Button>` plus a hidden `<input type="file">` plus a ref between them (seven
|
|
11
|
+
* copies in keksdose, two each in kastlan and lenkbank).
|
|
12
|
+
*
|
|
13
|
+
* Every copy had to remember the same four things, and each one forgot at least one:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Reset the input after every pick.** A file input fires `change` only when its
|
|
16
|
+
* value CHANGES, so picking the same file twice in a row — the retry after a failed
|
|
17
|
+
* upload, the second photo of the same receipt — did nothing at all, which reads as
|
|
18
|
+
* a broken button. `value = ""` after each pick; always, not per call site.
|
|
19
|
+
* 2. **`type="button"`.** `<Button>` does not set it, and inside a form a bare button
|
|
20
|
+
* submits the form before the picker opens.
|
|
21
|
+
* 3. **Check the file.** `accept` filters the DIALOG, not the result: the dialog's
|
|
22
|
+
* "All files" switch, a drop, and a mobile share sheet all hand over whatever the
|
|
23
|
+
* user chose. So `accept` is re-checked here, along with `maxSize`, `maxFiles` and
|
|
24
|
+
* an optional `isValid`.
|
|
25
|
+
* 4. **Say no without a toast.** A rejection is reported through `onReject`, with a
|
|
26
|
+
* translated message per file, and spoken through a live region — the kit does not
|
|
27
|
+
* decide how an app surfaces errors (keksdose's proposal §1 asked for exactly that).
|
|
28
|
+
*
|
|
29
|
+
* The part that is not a button — the hidden input, the check, the announcement — is
|
|
30
|
+
* {@link useFilePicker}, for the case where the thing that opens the picker is someone
|
|
31
|
+
* else's control (a card's "Add" action, a menu item, a second button for the camera).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/* ── Labels ──────────────────────────────────────────────────────────────── */
|
|
35
|
+
|
|
36
|
+
/** Every string the file pickers ({@link FileButton}, `FileDropzone`) render or speak.
|
|
37
|
+
* Messages are functions of the file name so a translation can put it anywhere. */
|
|
38
|
+
export interface FilePickerLabels {
|
|
39
|
+
/** The file's type is not in `accept`. */
|
|
40
|
+
rejectedType: (name: string) => string;
|
|
41
|
+
/** The file is larger than `maxSize`; `maxSize` arrives formatted ("5 MB"). */
|
|
42
|
+
rejectedSize: (name: string, maxSize: string) => string;
|
|
43
|
+
/** The file was one too many for `maxFiles`. */
|
|
44
|
+
rejectedCount: (name: string, maxFiles: number) => string;
|
|
45
|
+
/** `isValid` said no and the caller gave no `invalidMessage`. */
|
|
46
|
+
rejectedInvalid: (name: string) => string;
|
|
47
|
+
/** Spoken instead of the per-file message when more than one file was refused. */
|
|
48
|
+
rejectedMany: (count: number) => string;
|
|
49
|
+
/** Spoken after a pick the component itself echoes (the dropzone). */
|
|
50
|
+
selected: (count: number, firstName: string) => string;
|
|
51
|
+
/** The dropzone's remove button for one file. */
|
|
52
|
+
remove: (name: string) => string;
|
|
53
|
+
/** The dropzone's remove-everything button in `multiple` mode. */
|
|
54
|
+
clearAll: string;
|
|
55
|
+
/** Spoken after a remove / clear, since the button that was pressed is gone. */
|
|
56
|
+
removed: (name: string) => string;
|
|
57
|
+
cleared: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const DEFAULT_FILE_PICKER_LABELS: FilePickerLabels = {
|
|
61
|
+
rejectedType: (name) => `“${name}” is not a supported file type`,
|
|
62
|
+
rejectedSize: (name, maxSize) => `“${name}” is larger than ${maxSize}`,
|
|
63
|
+
rejectedCount: (name, maxFiles) =>
|
|
64
|
+
`“${name}” was not added: at most ${maxFiles} ${maxFiles === 1 ? "file" : "files"}`,
|
|
65
|
+
rejectedInvalid: (name) => `“${name}” cannot be used here`,
|
|
66
|
+
rejectedMany: (count) => `${count} files were not added`,
|
|
67
|
+
selected: (count, firstName) => (count === 1 ? `“${firstName}” selected` : `${count} files selected`),
|
|
68
|
+
remove: (name) => `Remove “${name}”`,
|
|
69
|
+
clearAll: "Remove all files",
|
|
70
|
+
removed: (name) => `“${name}” removed`,
|
|
71
|
+
cleared: "All files removed",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/* ── Screening ───────────────────────────────────────────────────────────── */
|
|
75
|
+
|
|
76
|
+
export type FileRejectionReason = "type" | "size" | "count" | "invalid";
|
|
77
|
+
|
|
78
|
+
/** One file the picker refused, and why. `message` is already translated (see
|
|
79
|
+
* {@link FilePickerLabels}, or the caller's `invalidMessage`), so a host that just
|
|
80
|
+
* wants to show it can render `rejections[0].message` as is. */
|
|
81
|
+
export interface FileRejection {
|
|
82
|
+
file: File;
|
|
83
|
+
reason: FileRejectionReason;
|
|
84
|
+
message: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Does `file` satisfy an `accept` string, the way the browser's dialog reads it?
|
|
89
|
+
* Comma-separated tokens: `.ext` (case-insensitive suffix of the name), `type/*`
|
|
90
|
+
* (a MIME family) or an exact MIME type. An empty or absent `accept` takes anything.
|
|
91
|
+
*
|
|
92
|
+
* A file with no `type` — common for `.step`, `.dat`, anything the OS has no MIME
|
|
93
|
+
* mapping for — can only match by extension, which is why lenkbank lists `.stp` AND
|
|
94
|
+
* `model/step`: that is how `accept` has to be written for the dialog anyway.
|
|
95
|
+
*/
|
|
96
|
+
export function matchesAccept(file: File, accept: string | undefined): boolean {
|
|
97
|
+
const tokens = (accept ?? "")
|
|
98
|
+
.split(",")
|
|
99
|
+
.map((t) => t.trim().toLowerCase())
|
|
100
|
+
.filter(Boolean);
|
|
101
|
+
if (tokens.length === 0) return true;
|
|
102
|
+
const name = file.name.toLowerCase();
|
|
103
|
+
const type = (file.type || "").toLowerCase();
|
|
104
|
+
return tokens.some((token) => {
|
|
105
|
+
if (token.startsWith(".")) return name.endsWith(token);
|
|
106
|
+
if (!type) return false;
|
|
107
|
+
if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1));
|
|
108
|
+
return type === token;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** What the pickers screen a pick with. All optional; nothing set accepts everything. */
|
|
113
|
+
export interface FileScreenOptions {
|
|
114
|
+
accept?: string;
|
|
115
|
+
/** Bytes. Larger files are refused with reason `"size"`. */
|
|
116
|
+
maxSize?: number;
|
|
117
|
+
/** How many files one pick may deliver. The rest are refused with reason `"count"`
|
|
118
|
+
* — first come, first kept. For a running cap ("at most 5 attachments") pass what
|
|
119
|
+
* is LEFT: `maxFiles={5 - attachments.length}`. */
|
|
120
|
+
maxFiles?: number;
|
|
121
|
+
/** The caller's own check, after `accept` and `maxSize`. */
|
|
122
|
+
isValid?: (file: File) => boolean;
|
|
123
|
+
/** The message for an `isValid` refusal; `labels.rejectedInvalid` otherwise. */
|
|
124
|
+
invalidMessage?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** @internal Split a pick into the files that pass and the ones that do not. */
|
|
128
|
+
export function screenFiles(
|
|
129
|
+
files: readonly File[],
|
|
130
|
+
opts: FileScreenOptions,
|
|
131
|
+
labels: FilePickerLabels,
|
|
132
|
+
formatSize: (bytes: number) => string,
|
|
133
|
+
): { accepted: File[]; rejected: FileRejection[] } {
|
|
134
|
+
const accepted: File[] = [];
|
|
135
|
+
const rejected: FileRejection[] = [];
|
|
136
|
+
for (const file of files) {
|
|
137
|
+
let reason: FileRejectionReason | null = null;
|
|
138
|
+
if (!matchesAccept(file, opts.accept)) reason = "type";
|
|
139
|
+
else if (opts.maxSize !== undefined && file.size > opts.maxSize) reason = "size";
|
|
140
|
+
else if (opts.isValid && !opts.isValid(file)) reason = "invalid";
|
|
141
|
+
// Count last, and only against files that passed everything else: a refused
|
|
142
|
+
// file should not use up one of the slots a good one could have had.
|
|
143
|
+
else if (opts.maxFiles !== undefined && accepted.length >= opts.maxFiles) reason = "count";
|
|
144
|
+
if (reason === null) {
|
|
145
|
+
accepted.push(file);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const message =
|
|
149
|
+
reason === "type"
|
|
150
|
+
? labels.rejectedType(file.name)
|
|
151
|
+
: reason === "size"
|
|
152
|
+
? labels.rejectedSize(file.name, formatSize(opts.maxSize ?? 0))
|
|
153
|
+
: reason === "count"
|
|
154
|
+
? labels.rejectedCount(file.name, Math.max(0, opts.maxFiles ?? 0))
|
|
155
|
+
: (opts.invalidMessage ?? labels.rejectedInvalid(file.name));
|
|
156
|
+
rejected.push({ file, reason, message });
|
|
157
|
+
}
|
|
158
|
+
return { accepted, rejected };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** One sentence for a whole batch of refusals: the file's own message for one, a
|
|
162
|
+
* count for several — reading out five sentences in a row helps nobody. */
|
|
163
|
+
export function summariseRejections(rejected: readonly FileRejection[], labels: FilePickerLabels): string {
|
|
164
|
+
if (rejected.length === 0) return "";
|
|
165
|
+
if (rejected.length === 1) return rejected[0].message;
|
|
166
|
+
return labels.rejectedMany(rejected.length);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/* ── useFilePicker ───────────────────────────────────────────────────────── */
|
|
170
|
+
|
|
171
|
+
export interface UseFilePickerOptions extends FileScreenOptions {
|
|
172
|
+
/** Let one pick deliver several files. Without it a drop of several keeps the first. */
|
|
173
|
+
multiple?: boolean;
|
|
174
|
+
/**
|
|
175
|
+
* Ask a phone for its camera instead of the file chooser: `"environment"` is the
|
|
176
|
+
* back camera, `"user"` the front. A HINT — desktop browsers ignore it, and Chrome
|
|
177
|
+
* drops it when `multiple` is also set (a camera cannot deliver a list), which is why
|
|
178
|
+
* keksdose's camera input has no `multiple`. Pass one or the other.
|
|
179
|
+
*/
|
|
180
|
+
capture?: boolean | "user" | "environment";
|
|
181
|
+
/** The files that passed, in pick order. Never called with an empty array. */
|
|
182
|
+
onFiles: (files: File[]) => void;
|
|
183
|
+
/** The files that did not, with a translated message each. The refusals are also
|
|
184
|
+
* spoken through a live region, so this is for SHOWING them, not for a11y. */
|
|
185
|
+
onReject?: (rejections: FileRejection[]) => void;
|
|
186
|
+
/** `open()` and `take()` do nothing while set. */
|
|
187
|
+
disabled?: boolean;
|
|
188
|
+
/** Per-instance overrides of the `filePicker` label namespace. */
|
|
189
|
+
labels?: Partial<FilePickerLabels>;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface UseFilePickerReturn {
|
|
193
|
+
/** Open the system picker. Call it from a click handler — browsers only open a file
|
|
194
|
+
* dialog in response to a user gesture. */
|
|
195
|
+
open: () => void;
|
|
196
|
+
/** Screen and deliver files that arrived some other way — a drop, a paste. Honours
|
|
197
|
+
* `multiple` (only the first file without it) and every check. */
|
|
198
|
+
take: (files: ArrayLike<File> | null | undefined) => void;
|
|
199
|
+
/** The hidden input and the live region. Render it once, anywhere — it takes no
|
|
200
|
+
* space and needs no positioned ancestor. */
|
|
201
|
+
element: ReactElement;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The headless half of {@link FileButton}: a hidden file input you can open from any
|
|
206
|
+
* control, with the reset, the screening and the announcement built in.
|
|
207
|
+
*
|
|
208
|
+
* ```tsx
|
|
209
|
+
* const picker = useFilePicker({ accept: ".pdf", onFiles: ([f]) => upload(f) });
|
|
210
|
+
* return <>{picker.element}<DetailTableCard onAdd={picker.open} … /></>;
|
|
211
|
+
* ```
|
|
212
|
+
*/
|
|
213
|
+
export function useFilePicker({
|
|
214
|
+
accept,
|
|
215
|
+
multiple,
|
|
216
|
+
capture,
|
|
217
|
+
maxSize,
|
|
218
|
+
maxFiles,
|
|
219
|
+
isValid,
|
|
220
|
+
invalidMessage,
|
|
221
|
+
onFiles,
|
|
222
|
+
onReject,
|
|
223
|
+
disabled,
|
|
224
|
+
labels: labelsProp,
|
|
225
|
+
}: UseFilePickerOptions): UseFilePickerReturn {
|
|
226
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
227
|
+
const labels = useKitLabels("filePicker", DEFAULT_FILE_PICKER_LABELS, labelsProp);
|
|
228
|
+
const fileText = useKitFileLabels();
|
|
229
|
+
// Assertive: a refusal is a failure the user has to hear before they move on, and
|
|
230
|
+
// it is the only thing this region ever says.
|
|
231
|
+
const { announce, regionProps } = useAnnounce({ politeness: "assertive" });
|
|
232
|
+
|
|
233
|
+
const take = (list: ArrayLike<File> | null | undefined) => {
|
|
234
|
+
if (disabled || !list || list.length === 0) return;
|
|
235
|
+
const all = Array.from(list);
|
|
236
|
+
const files = multiple ? all : all.slice(0, 1);
|
|
237
|
+
const { accepted, rejected } = screenFiles(
|
|
238
|
+
files,
|
|
239
|
+
// A single-file picker holds one file by definition; `maxFiles` is a multi-mode
|
|
240
|
+
// cap and would only ever say "0" there if a caller passed it by mistake.
|
|
241
|
+
{ accept, maxSize, maxFiles: multiple ? maxFiles : undefined, isValid, invalidMessage },
|
|
242
|
+
labels,
|
|
243
|
+
fileText.size,
|
|
244
|
+
);
|
|
245
|
+
if (accepted.length > 0) onFiles(accepted);
|
|
246
|
+
if (rejected.length > 0) {
|
|
247
|
+
onReject?.(rejected);
|
|
248
|
+
announce(summariseRejections(rejected, labels));
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const element = (
|
|
253
|
+
<>
|
|
254
|
+
<input
|
|
255
|
+
ref={inputRef}
|
|
256
|
+
type="file"
|
|
257
|
+
accept={accept}
|
|
258
|
+
multiple={multiple}
|
|
259
|
+
capture={capture}
|
|
260
|
+
disabled={disabled}
|
|
261
|
+
// `hidden` (display: none), not `sr-only`: the control a user reaches is the
|
|
262
|
+
// button, and a second, nameless tab stop for the same action is noise. A
|
|
263
|
+
// display:none file input still opens on `.click()` in every current browser —
|
|
264
|
+
// and, unlike `sr-only`, it cannot escape to the initial containing block and
|
|
265
|
+
// stretch the page (see the note in `file-dropzone.tsx`).
|
|
266
|
+
className="hidden"
|
|
267
|
+
tabIndex={-1}
|
|
268
|
+
aria-hidden
|
|
269
|
+
onChange={(e) => {
|
|
270
|
+
// Copy BEFORE the reset: `files` is a live view of the input's value, and
|
|
271
|
+
// clearing the value empties it.
|
|
272
|
+
const picked = Array.from(e.currentTarget.files ?? []);
|
|
273
|
+
e.currentTarget.value = "";
|
|
274
|
+
take(picked);
|
|
275
|
+
}}
|
|
276
|
+
/>
|
|
277
|
+
<span {...regionProps} />
|
|
278
|
+
</>
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
open: () => {
|
|
283
|
+
if (!disabled) inputRef.current?.click();
|
|
284
|
+
},
|
|
285
|
+
take,
|
|
286
|
+
element,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/* ── FileButton ──────────────────────────────────────────────────────────── */
|
|
291
|
+
|
|
292
|
+
type ButtonOwnProps = ComponentPropsWithoutRef<typeof Button>;
|
|
293
|
+
|
|
294
|
+
export interface FileButtonProps
|
|
295
|
+
extends Omit<UseFilePickerOptions, "disabled">,
|
|
296
|
+
// `onDrop` & co. stay the caller's own when `droppable` is off; `type` is fixed to
|
|
297
|
+
// "button" (see 2. above) — a file trigger never submits a form.
|
|
298
|
+
Omit<ButtonOwnProps, "type" | "children" | "accept" | "capture" | "multiple"> {
|
|
299
|
+
/** The button's content — usually an icon and a word. It is the accessible name. */
|
|
300
|
+
children: ReactNode;
|
|
301
|
+
/** Busy: disabled, `aria-busy`, and a spinner before the content — for "uploading". */
|
|
302
|
+
pending?: boolean;
|
|
303
|
+
/** Also accept files dropped ON the button (lenkbank's "drop onto the button").
|
|
304
|
+
* Off by default: a button that silently takes drops is a surprise on a page that
|
|
305
|
+
* has a real drop target elsewhere. */
|
|
306
|
+
droppable?: boolean;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* {@link useFilePicker} behind a {@link Button}: `variant` and every other button prop
|
|
311
|
+
* pass through, and the ref is the `<button>` (so `ref.current.click()` opens the
|
|
312
|
+
* picker from elsewhere too).
|
|
313
|
+
*
|
|
314
|
+
* ```tsx
|
|
315
|
+
* <FileButton accept="image/*,application/pdf" capture="environment" variant="secondary"
|
|
316
|
+
* maxSize={10_000_000} onFiles={([f]) => upload(f)} onReject={([r]) => setError(r.message)}>
|
|
317
|
+
* <Camera aria-hidden /> Photograph receipt
|
|
318
|
+
* </FileButton>
|
|
319
|
+
* ```
|
|
320
|
+
*/
|
|
321
|
+
export const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(function FileButton(
|
|
322
|
+
{
|
|
323
|
+
accept,
|
|
324
|
+
multiple,
|
|
325
|
+
capture,
|
|
326
|
+
maxSize,
|
|
327
|
+
maxFiles,
|
|
328
|
+
isValid,
|
|
329
|
+
invalidMessage,
|
|
330
|
+
onFiles,
|
|
331
|
+
onReject,
|
|
332
|
+
labels,
|
|
333
|
+
pending,
|
|
334
|
+
droppable,
|
|
335
|
+
disabled,
|
|
336
|
+
children,
|
|
337
|
+
className,
|
|
338
|
+
onClick,
|
|
339
|
+
onDragEnter,
|
|
340
|
+
onDragOver,
|
|
341
|
+
onDragLeave,
|
|
342
|
+
onDrop,
|
|
343
|
+
...rest
|
|
344
|
+
},
|
|
345
|
+
ref,
|
|
346
|
+
) {
|
|
347
|
+
const inert = Boolean(disabled || pending);
|
|
348
|
+
const picker = useFilePicker({
|
|
349
|
+
accept,
|
|
350
|
+
multiple,
|
|
351
|
+
capture,
|
|
352
|
+
maxSize,
|
|
353
|
+
maxFiles,
|
|
354
|
+
isValid,
|
|
355
|
+
invalidMessage,
|
|
356
|
+
onFiles,
|
|
357
|
+
onReject,
|
|
358
|
+
labels,
|
|
359
|
+
disabled: inert,
|
|
360
|
+
});
|
|
361
|
+
const [dragOver, setDragOver] = useState(false);
|
|
362
|
+
|
|
363
|
+
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files");
|
|
364
|
+
// Only when `droppable`: otherwise the caller's own handlers are passed untouched.
|
|
365
|
+
const dropHandlers = droppable
|
|
366
|
+
? {
|
|
367
|
+
onDragEnter: (e: DragEvent<HTMLButtonElement>) => {
|
|
368
|
+
onDragEnter?.(e);
|
|
369
|
+
if (!hasFiles(e)) return;
|
|
370
|
+
e.preventDefault();
|
|
371
|
+
if (!inert) setDragOver(true);
|
|
372
|
+
},
|
|
373
|
+
onDragOver: (e: DragEvent<HTMLButtonElement>) => {
|
|
374
|
+
onDragOver?.(e);
|
|
375
|
+
if (!hasFiles(e)) return;
|
|
376
|
+
// Always cancel for a file drag, busy or not: an uncancelled drop makes the
|
|
377
|
+
// browser NAVIGATE to the file, which loses the page.
|
|
378
|
+
e.preventDefault();
|
|
379
|
+
e.dataTransfer.dropEffect = inert ? "none" : "copy";
|
|
380
|
+
},
|
|
381
|
+
onDragLeave: (e: DragEvent<HTMLButtonElement>) => {
|
|
382
|
+
onDragLeave?.(e);
|
|
383
|
+
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
|
384
|
+
setDragOver(false);
|
|
385
|
+
},
|
|
386
|
+
onDrop: (e: DragEvent<HTMLButtonElement>) => {
|
|
387
|
+
onDrop?.(e);
|
|
388
|
+
if (!hasFiles(e)) return;
|
|
389
|
+
e.preventDefault();
|
|
390
|
+
setDragOver(false);
|
|
391
|
+
picker.take(e.dataTransfer.files);
|
|
392
|
+
},
|
|
393
|
+
}
|
|
394
|
+
: { onDragEnter, onDragOver, onDragLeave, onDrop };
|
|
395
|
+
|
|
396
|
+
// `Button` is a plain function component; under React 19 `ref` is an ordinary prop
|
|
397
|
+
// and reaches the `<button>` through its `...rest`. Its props type just does not
|
|
398
|
+
// declare it, hence the widening here rather than a second button implementation.
|
|
399
|
+
const refProp = { ref } as { ref?: Ref<HTMLButtonElement> };
|
|
400
|
+
|
|
401
|
+
return (
|
|
402
|
+
<>
|
|
403
|
+
<Button
|
|
404
|
+
{...rest}
|
|
405
|
+
{...refProp}
|
|
406
|
+
{...dropHandlers}
|
|
407
|
+
type="button"
|
|
408
|
+
disabled={inert}
|
|
409
|
+
aria-busy={pending || undefined}
|
|
410
|
+
data-drag-over={dragOver || undefined}
|
|
411
|
+
className={cn(
|
|
412
|
+
// A live drag gets the focus ring's colour as a ring: "let go here", in the
|
|
413
|
+
// same vocabulary the button already uses for "you are here".
|
|
414
|
+
dragOver && "ring-2 ring-[var(--brand)]",
|
|
415
|
+
className,
|
|
416
|
+
)}
|
|
417
|
+
onClick={(e) => {
|
|
418
|
+
onClick?.(e);
|
|
419
|
+
if (!e.defaultPrevented) picker.open();
|
|
420
|
+
}}
|
|
421
|
+
>
|
|
422
|
+
{/* `label={null}`: the spinner is decoration here — `aria-busy` says the same
|
|
423
|
+
thing on the button, and a spoken "Loading" would run into its name. */}
|
|
424
|
+
{pending && <Spinner label={null} className="size-4" />}
|
|
425
|
+
{children}
|
|
426
|
+
</Button>
|
|
427
|
+
{picker.element}
|
|
428
|
+
</>
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
FileButton.displayName = "FileButton";
|