@eifi1/ui-kit 0.5.1 → 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.
Files changed (128) hide show
  1. package/README.md +50 -1
  2. package/dist/components/amount-input.d.ts +7 -0
  3. package/dist/components/autocomplete.d.ts +101 -0
  4. package/dist/components/autocomplete.js +260 -0
  5. package/dist/components/autocomplete.js.map +1 -0
  6. package/dist/components/calculator.d.ts +7 -0
  7. package/dist/components/chip.d.ts +3 -2
  8. package/dist/components/chip.js +14 -1
  9. package/dist/components/chip.js.map +1 -1
  10. package/dist/components/choice-card.d.ts +100 -0
  11. package/dist/components/choice-card.js +170 -0
  12. package/dist/components/choice-card.js.map +1 -0
  13. package/dist/components/combobox-core.d.ts +76 -6
  14. package/dist/components/combobox-core.js +119 -49
  15. package/dist/components/combobox-core.js.map +1 -1
  16. package/dist/components/combobox.d.ts +12 -2
  17. package/dist/components/combobox.js +42 -17
  18. package/dist/components/combobox.js.map +1 -1
  19. package/dist/components/danger-confirm.d.ts +91 -0
  20. package/dist/components/danger-confirm.js +181 -0
  21. package/dist/components/danger-confirm.js.map +1 -0
  22. package/dist/components/dialog-frame.d.ts +84 -0
  23. package/dist/components/dialog-frame.js +86 -0
  24. package/dist/components/dialog-frame.js.map +1 -0
  25. package/dist/components/disclosure.d.ts +108 -0
  26. package/dist/components/disclosure.js +127 -0
  27. package/dist/components/disclosure.js.map +1 -0
  28. package/dist/components/entity-combobox.d.ts +17 -3
  29. package/dist/components/entity-combobox.js +25 -5
  30. package/dist/components/entity-combobox.js.map +1 -1
  31. package/dist/components/file-button.d.ts +161 -0
  32. package/dist/components/file-button.js +225 -0
  33. package/dist/components/file-button.js.map +1 -0
  34. package/dist/components/file-dropzone.d.ts +72 -23
  35. package/dist/components/file-dropzone.js +219 -94
  36. package/dist/components/file-dropzone.js.map +1 -1
  37. package/dist/components/icon-picker.d.ts +72 -0
  38. package/dist/components/icon-picker.js +104 -0
  39. package/dist/components/icon-picker.js.map +1 -0
  40. package/dist/components/mini-calendar.d.ts +3 -0
  41. package/dist/components/mini-calendar.js +4 -3
  42. package/dist/components/mini-calendar.js.map +1 -1
  43. package/dist/components/modal.d.ts +8 -1
  44. package/dist/components/modal.js +4 -2
  45. package/dist/components/modal.js.map +1 -1
  46. package/dist/components/multi-entity-combobox.d.ts +16 -3
  47. package/dist/components/multi-entity-combobox.js +25 -5
  48. package/dist/components/multi-entity-combobox.js.map +1 -1
  49. package/dist/components/number-field.d.ts +41 -1
  50. package/dist/components/number-field.js +42 -10
  51. package/dist/components/number-field.js.map +1 -1
  52. package/dist/components/number-input.d.ts +35 -2
  53. package/dist/components/number-input.js +35 -4
  54. package/dist/components/number-input.js.map +1 -1
  55. package/dist/components/numpad-sheet.d.ts +7 -0
  56. package/dist/components/search-field.d.ts +16 -0
  57. package/dist/components/search-field.js +29 -7
  58. package/dist/components/search-field.js.map +1 -1
  59. package/dist/components/signature-pad.d.ts +43 -1
  60. package/dist/components/signature-pad.js +74 -2
  61. package/dist/components/signature-pad.js.map +1 -1
  62. package/dist/components/swatch-picker.d.ts +69 -0
  63. package/dist/components/swatch-picker.js +75 -0
  64. package/dist/components/swatch-picker.js.map +1 -0
  65. package/dist/components/tile-radio.d.ts +50 -0
  66. package/dist/components/tile-radio.js +140 -0
  67. package/dist/components/tile-radio.js.map +1 -0
  68. package/dist/components/toggle-group.d.ts +27 -5
  69. package/dist/components/toggle-group.js +22 -15
  70. package/dist/components/toggle-group.js.map +1 -1
  71. package/dist/components/ui.d.ts +150 -12
  72. package/dist/components/ui.js +196 -22
  73. package/dist/components/ui.js.map +1 -1
  74. package/dist/i18n/defaults.d.ts +7 -0
  75. package/dist/i18n/defaults.js +13 -2
  76. package/dist/i18n/defaults.js.map +1 -1
  77. package/dist/i18n/kit-labels.d.ts +38 -5
  78. package/dist/i18n/kit-labels.js +12 -4
  79. package/dist/i18n/kit-labels.js.map +1 -1
  80. package/dist/index.d.ts +16 -7
  81. package/dist/index.js +12 -0
  82. package/dist/index.js.map +1 -1
  83. package/dist/lib/table-text.d.ts +127 -0
  84. package/dist/lib/table-text.js +82 -0
  85. package/dist/lib/table-text.js.map +1 -0
  86. package/dist/rhf/form.d.ts +79 -0
  87. package/dist/rhf/form.js +143 -0
  88. package/dist/rhf/form.js.map +1 -0
  89. package/dist/rhf.d.ts +4 -0
  90. package/dist/rhf.js +3 -0
  91. package/dist/rhf.js.map +1 -0
  92. package/dist/shell/app-shell.js +3 -1
  93. package/dist/shell/app-shell.js.map +1 -1
  94. package/dist/table-text.d.ts +1 -0
  95. package/dist/table-text.js +3 -0
  96. package/dist/table-text.js.map +1 -0
  97. package/package.json +14 -1
  98. package/src/components/autocomplete.tsx +425 -0
  99. package/src/components/chip.tsx +24 -2
  100. package/src/components/choice-card.tsx +305 -0
  101. package/src/components/combobox-core.tsx +228 -58
  102. package/src/components/combobox.tsx +58 -21
  103. package/src/components/danger-confirm.tsx +286 -0
  104. package/src/components/dialog-frame.tsx +179 -0
  105. package/src/components/disclosure.tsx +259 -0
  106. package/src/components/entity-combobox.tsx +41 -6
  107. package/src/components/file-button.tsx +431 -0
  108. package/src/components/file-dropzone.tsx +323 -117
  109. package/src/components/icon-picker.tsx +181 -0
  110. package/src/components/mini-calendar.tsx +7 -3
  111. package/src/components/modal.tsx +10 -2
  112. package/src/components/multi-entity-combobox.tsx +40 -6
  113. package/src/components/number-field.tsx +86 -10
  114. package/src/components/number-input.tsx +79 -2
  115. package/src/components/search-field.tsx +49 -6
  116. package/src/components/signature-pad.tsx +112 -0
  117. package/src/components/swatch-picker.tsx +141 -0
  118. package/src/components/tile-radio.tsx +228 -0
  119. package/src/components/toggle-group.tsx +54 -18
  120. package/src/components/ui.tsx +400 -24
  121. package/src/i18n/defaults.ts +12 -1
  122. package/src/i18n/kit-labels.tsx +45 -5
  123. package/src/index.ts +19 -0
  124. package/src/lib/table-text.ts +265 -0
  125. package/src/rhf/form.tsx +300 -0
  126. package/src/rhf.ts +9 -0
  127. package/src/shell/app-shell.tsx +3 -1
  128. 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";