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