@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
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ComponentPropsWithoutRef, ReactNode, ReactElement } from 'react';
|
|
3
|
+
import { Button } from './ui.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A button that opens the file picker — the shape all three apps kept writing by hand
|
|
7
|
+
* as a `<Button>` plus a hidden `<input type="file">` plus a ref between them (seven
|
|
8
|
+
* copies in keksdose, two each in kastlan and lenkbank).
|
|
9
|
+
*
|
|
10
|
+
* Every copy had to remember the same four things, and each one forgot at least one:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Reset the input after every pick.** A file input fires `change` only when its
|
|
13
|
+
* value CHANGES, so picking the same file twice in a row — the retry after a failed
|
|
14
|
+
* upload, the second photo of the same receipt — did nothing at all, which reads as
|
|
15
|
+
* a broken button. `value = ""` after each pick; always, not per call site.
|
|
16
|
+
* 2. **`type="button"`.** `<Button>` does not set it, and inside a form a bare button
|
|
17
|
+
* submits the form before the picker opens.
|
|
18
|
+
* 3. **Check the file.** `accept` filters the DIALOG, not the result: the dialog's
|
|
19
|
+
* "All files" switch, a drop, and a mobile share sheet all hand over whatever the
|
|
20
|
+
* user chose. So `accept` is re-checked here, along with `maxSize`, `maxFiles` and
|
|
21
|
+
* an optional `isValid`.
|
|
22
|
+
* 4. **Say no without a toast.** A rejection is reported through `onReject`, with a
|
|
23
|
+
* translated message per file, and spoken through a live region — the kit does not
|
|
24
|
+
* decide how an app surfaces errors (keksdose's proposal §1 asked for exactly that).
|
|
25
|
+
*
|
|
26
|
+
* The part that is not a button — the hidden input, the check, the announcement — is
|
|
27
|
+
* {@link useFilePicker}, for the case where the thing that opens the picker is someone
|
|
28
|
+
* else's control (a card's "Add" action, a menu item, a second button for the camera).
|
|
29
|
+
*/
|
|
30
|
+
/** Every string the file pickers ({@link FileButton}, `FileDropzone`) render or speak.
|
|
31
|
+
* Messages are functions of the file name so a translation can put it anywhere. */
|
|
32
|
+
interface FilePickerLabels {
|
|
33
|
+
/** The file's type is not in `accept`. */
|
|
34
|
+
rejectedType: (name: string) => string;
|
|
35
|
+
/** The file is larger than `maxSize`; `maxSize` arrives formatted ("5 MB"). */
|
|
36
|
+
rejectedSize: (name: string, maxSize: string) => string;
|
|
37
|
+
/** The file was one too many for `maxFiles`. */
|
|
38
|
+
rejectedCount: (name: string, maxFiles: number) => string;
|
|
39
|
+
/** `isValid` said no and the caller gave no `invalidMessage`. */
|
|
40
|
+
rejectedInvalid: (name: string) => string;
|
|
41
|
+
/** Spoken instead of the per-file message when more than one file was refused. */
|
|
42
|
+
rejectedMany: (count: number) => string;
|
|
43
|
+
/** Spoken after a pick the component itself echoes (the dropzone). */
|
|
44
|
+
selected: (count: number, firstName: string) => string;
|
|
45
|
+
/** The dropzone's remove button for one file. */
|
|
46
|
+
remove: (name: string) => string;
|
|
47
|
+
/** The dropzone's remove-everything button in `multiple` mode. */
|
|
48
|
+
clearAll: string;
|
|
49
|
+
/** Spoken after a remove / clear, since the button that was pressed is gone. */
|
|
50
|
+
removed: (name: string) => string;
|
|
51
|
+
cleared: string;
|
|
52
|
+
}
|
|
53
|
+
declare const DEFAULT_FILE_PICKER_LABELS: FilePickerLabels;
|
|
54
|
+
type FileRejectionReason = "type" | "size" | "count" | "invalid";
|
|
55
|
+
/** One file the picker refused, and why. `message` is already translated (see
|
|
56
|
+
* {@link FilePickerLabels}, or the caller's `invalidMessage`), so a host that just
|
|
57
|
+
* wants to show it can render `rejections[0].message` as is. */
|
|
58
|
+
interface FileRejection {
|
|
59
|
+
file: File;
|
|
60
|
+
reason: FileRejectionReason;
|
|
61
|
+
message: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Does `file` satisfy an `accept` string, the way the browser's dialog reads it?
|
|
65
|
+
* Comma-separated tokens: `.ext` (case-insensitive suffix of the name), `type/*`
|
|
66
|
+
* (a MIME family) or an exact MIME type. An empty or absent `accept` takes anything.
|
|
67
|
+
*
|
|
68
|
+
* A file with no `type` — common for `.step`, `.dat`, anything the OS has no MIME
|
|
69
|
+
* mapping for — can only match by extension, which is why lenkbank lists `.stp` AND
|
|
70
|
+
* `model/step`: that is how `accept` has to be written for the dialog anyway.
|
|
71
|
+
*/
|
|
72
|
+
declare function matchesAccept(file: File, accept: string | undefined): boolean;
|
|
73
|
+
/** What the pickers screen a pick with. All optional; nothing set accepts everything. */
|
|
74
|
+
interface FileScreenOptions {
|
|
75
|
+
accept?: string;
|
|
76
|
+
/** Bytes. Larger files are refused with reason `"size"`. */
|
|
77
|
+
maxSize?: number;
|
|
78
|
+
/** How many files one pick may deliver. The rest are refused with reason `"count"`
|
|
79
|
+
* — first come, first kept. For a running cap ("at most 5 attachments") pass what
|
|
80
|
+
* is LEFT: `maxFiles={5 - attachments.length}`. */
|
|
81
|
+
maxFiles?: number;
|
|
82
|
+
/** The caller's own check, after `accept` and `maxSize`. */
|
|
83
|
+
isValid?: (file: File) => boolean;
|
|
84
|
+
/** The message for an `isValid` refusal; `labels.rejectedInvalid` otherwise. */
|
|
85
|
+
invalidMessage?: string;
|
|
86
|
+
}
|
|
87
|
+
/** @internal Split a pick into the files that pass and the ones that do not. */
|
|
88
|
+
declare function screenFiles(files: readonly File[], opts: FileScreenOptions, labels: FilePickerLabels, formatSize: (bytes: number) => string): {
|
|
89
|
+
accepted: File[];
|
|
90
|
+
rejected: FileRejection[];
|
|
91
|
+
};
|
|
92
|
+
/** One sentence for a whole batch of refusals: the file's own message for one, a
|
|
93
|
+
* count for several — reading out five sentences in a row helps nobody. */
|
|
94
|
+
declare function summariseRejections(rejected: readonly FileRejection[], labels: FilePickerLabels): string;
|
|
95
|
+
interface UseFilePickerOptions extends FileScreenOptions {
|
|
96
|
+
/** Let one pick deliver several files. Without it a drop of several keeps the first. */
|
|
97
|
+
multiple?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Ask a phone for its camera instead of the file chooser: `"environment"` is the
|
|
100
|
+
* back camera, `"user"` the front. A HINT — desktop browsers ignore it, and Chrome
|
|
101
|
+
* drops it when `multiple` is also set (a camera cannot deliver a list), which is why
|
|
102
|
+
* keksdose's camera input has no `multiple`. Pass one or the other.
|
|
103
|
+
*/
|
|
104
|
+
capture?: boolean | "user" | "environment";
|
|
105
|
+
/** The files that passed, in pick order. Never called with an empty array. */
|
|
106
|
+
onFiles: (files: File[]) => void;
|
|
107
|
+
/** The files that did not, with a translated message each. The refusals are also
|
|
108
|
+
* spoken through a live region, so this is for SHOWING them, not for a11y. */
|
|
109
|
+
onReject?: (rejections: FileRejection[]) => void;
|
|
110
|
+
/** `open()` and `take()` do nothing while set. */
|
|
111
|
+
disabled?: boolean;
|
|
112
|
+
/** Per-instance overrides of the `filePicker` label namespace. */
|
|
113
|
+
labels?: Partial<FilePickerLabels>;
|
|
114
|
+
}
|
|
115
|
+
interface UseFilePickerReturn {
|
|
116
|
+
/** Open the system picker. Call it from a click handler — browsers only open a file
|
|
117
|
+
* dialog in response to a user gesture. */
|
|
118
|
+
open: () => void;
|
|
119
|
+
/** Screen and deliver files that arrived some other way — a drop, a paste. Honours
|
|
120
|
+
* `multiple` (only the first file without it) and every check. */
|
|
121
|
+
take: (files: ArrayLike<File> | null | undefined) => void;
|
|
122
|
+
/** The hidden input and the live region. Render it once, anywhere — it takes no
|
|
123
|
+
* space and needs no positioned ancestor. */
|
|
124
|
+
element: ReactElement;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The headless half of {@link FileButton}: a hidden file input you can open from any
|
|
128
|
+
* control, with the reset, the screening and the announcement built in.
|
|
129
|
+
*
|
|
130
|
+
* ```tsx
|
|
131
|
+
* const picker = useFilePicker({ accept: ".pdf", onFiles: ([f]) => upload(f) });
|
|
132
|
+
* return <>{picker.element}<DetailTableCard onAdd={picker.open} … /></>;
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
declare function useFilePicker({ accept, multiple, capture, maxSize, maxFiles, isValid, invalidMessage, onFiles, onReject, disabled, labels: labelsProp, }: UseFilePickerOptions): UseFilePickerReturn;
|
|
136
|
+
type ButtonOwnProps = ComponentPropsWithoutRef<typeof Button>;
|
|
137
|
+
interface FileButtonProps extends Omit<UseFilePickerOptions, "disabled">, Omit<ButtonOwnProps, "type" | "children" | "accept" | "capture" | "multiple"> {
|
|
138
|
+
/** The button's content — usually an icon and a word. It is the accessible name. */
|
|
139
|
+
children: ReactNode;
|
|
140
|
+
/** Busy: disabled, `aria-busy`, and a spinner before the content — for "uploading". */
|
|
141
|
+
pending?: boolean;
|
|
142
|
+
/** Also accept files dropped ON the button (lenkbank's "drop onto the button").
|
|
143
|
+
* Off by default: a button that silently takes drops is a surprise on a page that
|
|
144
|
+
* has a real drop target elsewhere. */
|
|
145
|
+
droppable?: boolean;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* {@link useFilePicker} behind a {@link Button}: `variant` and every other button prop
|
|
149
|
+
* pass through, and the ref is the `<button>` (so `ref.current.click()` opens the
|
|
150
|
+
* picker from elsewhere too).
|
|
151
|
+
*
|
|
152
|
+
* ```tsx
|
|
153
|
+
* <FileButton accept="image/*,application/pdf" capture="environment" variant="secondary"
|
|
154
|
+
* maxSize={10_000_000} onFiles={([f]) => upload(f)} onReject={([r]) => setError(r.message)}>
|
|
155
|
+
* <Camera aria-hidden /> Photograph receipt
|
|
156
|
+
* </FileButton>
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
declare const FileButton: react.ForwardRefExoticComponent<FileButtonProps & react.RefAttributes<HTMLButtonElement>>;
|
|
160
|
+
|
|
161
|
+
export { DEFAULT_FILE_PICKER_LABELS, FileButton, type FileButtonProps, type FilePickerLabels, type FileRejection, type FileRejectionReason, type FileScreenOptions, type UseFilePickerOptions, type UseFilePickerReturn, matchesAccept, screenFiles, summariseRejections, useFilePicker };
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { forwardRef, useRef, useState } from "react";
|
|
4
|
+
import { cn } from "../lib/cn.js";
|
|
5
|
+
import { useAnnounce } from "../hooks/use-announce.js";
|
|
6
|
+
import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels.js";
|
|
7
|
+
import { Button, Spinner } from "./ui.js";
|
|
8
|
+
const DEFAULT_FILE_PICKER_LABELS = {
|
|
9
|
+
rejectedType: (name) => `\u201C${name}\u201D is not a supported file type`,
|
|
10
|
+
rejectedSize: (name, maxSize) => `\u201C${name}\u201D is larger than ${maxSize}`,
|
|
11
|
+
rejectedCount: (name, maxFiles) => `\u201C${name}\u201D was not added: at most ${maxFiles} ${maxFiles === 1 ? "file" : "files"}`,
|
|
12
|
+
rejectedInvalid: (name) => `\u201C${name}\u201D cannot be used here`,
|
|
13
|
+
rejectedMany: (count) => `${count} files were not added`,
|
|
14
|
+
selected: (count, firstName) => count === 1 ? `\u201C${firstName}\u201D selected` : `${count} files selected`,
|
|
15
|
+
remove: (name) => `Remove \u201C${name}\u201D`,
|
|
16
|
+
clearAll: "Remove all files",
|
|
17
|
+
removed: (name) => `\u201C${name}\u201D removed`,
|
|
18
|
+
cleared: "All files removed"
|
|
19
|
+
};
|
|
20
|
+
function matchesAccept(file, accept) {
|
|
21
|
+
const tokens = (accept ?? "").split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
22
|
+
if (tokens.length === 0) return true;
|
|
23
|
+
const name = file.name.toLowerCase();
|
|
24
|
+
const type = (file.type || typeFromExtension(name) || "").toLowerCase();
|
|
25
|
+
if (!type && !tokens.every((t) => t.startsWith("."))) return true;
|
|
26
|
+
return tokens.some((token) => {
|
|
27
|
+
if (token.startsWith(".")) return name.endsWith(token);
|
|
28
|
+
if (!type) return false;
|
|
29
|
+
if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1));
|
|
30
|
+
return type === token;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const EXTENSION_TYPES = {
|
|
34
|
+
heic: "image/heic",
|
|
35
|
+
heif: "image/heif",
|
|
36
|
+
avif: "image/avif",
|
|
37
|
+
webp: "image/webp",
|
|
38
|
+
jpg: "image/jpeg",
|
|
39
|
+
jpeg: "image/jpeg",
|
|
40
|
+
png: "image/png",
|
|
41
|
+
gif: "image/gif",
|
|
42
|
+
pdf: "application/pdf",
|
|
43
|
+
csv: "text/csv",
|
|
44
|
+
txt: "text/plain"
|
|
45
|
+
};
|
|
46
|
+
function typeFromExtension(name) {
|
|
47
|
+
const dot = name.lastIndexOf(".");
|
|
48
|
+
return dot === -1 ? void 0 : EXTENSION_TYPES[name.slice(dot + 1)];
|
|
49
|
+
}
|
|
50
|
+
function screenFiles(files, opts, labels, formatSize) {
|
|
51
|
+
const accepted = [];
|
|
52
|
+
const rejected = [];
|
|
53
|
+
for (const file of files) {
|
|
54
|
+
let reason = null;
|
|
55
|
+
if (!matchesAccept(file, opts.accept)) reason = "type";
|
|
56
|
+
else if (opts.maxSize !== void 0 && file.size > opts.maxSize) reason = "size";
|
|
57
|
+
else if (opts.isValid && !opts.isValid(file)) reason = "invalid";
|
|
58
|
+
else if (opts.maxFiles !== void 0 && accepted.length >= opts.maxFiles) reason = "count";
|
|
59
|
+
if (reason === null) {
|
|
60
|
+
accepted.push(file);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const message = reason === "type" ? labels.rejectedType(file.name) : reason === "size" ? labels.rejectedSize(file.name, formatSize(opts.maxSize ?? 0)) : reason === "count" ? labels.rejectedCount(file.name, Math.max(0, opts.maxFiles ?? 0)) : opts.invalidMessage ?? labels.rejectedInvalid(file.name);
|
|
64
|
+
rejected.push({ file, reason, message });
|
|
65
|
+
}
|
|
66
|
+
return { accepted, rejected };
|
|
67
|
+
}
|
|
68
|
+
function summariseRejections(rejected, labels) {
|
|
69
|
+
if (rejected.length === 0) return "";
|
|
70
|
+
if (rejected.length === 1) return rejected[0].message;
|
|
71
|
+
return labels.rejectedMany(rejected.length);
|
|
72
|
+
}
|
|
73
|
+
function useFilePicker({
|
|
74
|
+
accept,
|
|
75
|
+
multiple,
|
|
76
|
+
capture,
|
|
77
|
+
maxSize,
|
|
78
|
+
maxFiles,
|
|
79
|
+
isValid,
|
|
80
|
+
invalidMessage,
|
|
81
|
+
onFiles,
|
|
82
|
+
onReject,
|
|
83
|
+
disabled,
|
|
84
|
+
labels: labelsProp
|
|
85
|
+
}) {
|
|
86
|
+
const inputRef = useRef(null);
|
|
87
|
+
const labels = useKitLabels("filePicker", DEFAULT_FILE_PICKER_LABELS, labelsProp);
|
|
88
|
+
const fileText = useKitFileLabels();
|
|
89
|
+
const { announce, regionProps } = useAnnounce({ politeness: "assertive" });
|
|
90
|
+
const take = (list) => {
|
|
91
|
+
if (disabled || !list || list.length === 0) return;
|
|
92
|
+
const all = Array.from(list);
|
|
93
|
+
const files = multiple ? all : all.slice(0, 1);
|
|
94
|
+
const { accepted, rejected } = screenFiles(
|
|
95
|
+
files,
|
|
96
|
+
// A single-file picker holds one file by definition; `maxFiles` is a multi-mode
|
|
97
|
+
// cap and would only ever say "0" there if a caller passed it by mistake.
|
|
98
|
+
{ accept, maxSize, maxFiles: multiple ? maxFiles : void 0, isValid, invalidMessage },
|
|
99
|
+
labels,
|
|
100
|
+
fileText.size
|
|
101
|
+
);
|
|
102
|
+
if (accepted.length > 0) onFiles(accepted);
|
|
103
|
+
if (rejected.length > 0) {
|
|
104
|
+
onReject?.(rejected);
|
|
105
|
+
announce(summariseRejections(rejected, labels));
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const element = /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
109
|
+
/* @__PURE__ */ jsx(
|
|
110
|
+
"input",
|
|
111
|
+
{
|
|
112
|
+
ref: inputRef,
|
|
113
|
+
type: "file",
|
|
114
|
+
accept,
|
|
115
|
+
multiple,
|
|
116
|
+
capture,
|
|
117
|
+
disabled,
|
|
118
|
+
className: "hidden",
|
|
119
|
+
tabIndex: -1,
|
|
120
|
+
"aria-hidden": true,
|
|
121
|
+
onChange: (e) => {
|
|
122
|
+
const picked = Array.from(e.currentTarget.files ?? []);
|
|
123
|
+
e.currentTarget.value = "";
|
|
124
|
+
take(picked);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
),
|
|
128
|
+
/* @__PURE__ */ jsx("span", { ...regionProps })
|
|
129
|
+
] });
|
|
130
|
+
return {
|
|
131
|
+
open: () => {
|
|
132
|
+
if (!disabled) inputRef.current?.click();
|
|
133
|
+
},
|
|
134
|
+
take,
|
|
135
|
+
element
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const FileButton = forwardRef(function FileButton2({
|
|
139
|
+
accept,
|
|
140
|
+
multiple,
|
|
141
|
+
capture,
|
|
142
|
+
maxSize,
|
|
143
|
+
maxFiles,
|
|
144
|
+
isValid,
|
|
145
|
+
invalidMessage,
|
|
146
|
+
onFiles,
|
|
147
|
+
onReject,
|
|
148
|
+
labels,
|
|
149
|
+
pending,
|
|
150
|
+
droppable,
|
|
151
|
+
disabled,
|
|
152
|
+
children,
|
|
153
|
+
className,
|
|
154
|
+
onClick,
|
|
155
|
+
onDragEnter,
|
|
156
|
+
onDragOver,
|
|
157
|
+
onDragLeave,
|
|
158
|
+
onDrop,
|
|
159
|
+
...rest
|
|
160
|
+
}, ref) {
|
|
161
|
+
const inert = Boolean(disabled || pending);
|
|
162
|
+
const picker = useFilePicker({
|
|
163
|
+
accept,
|
|
164
|
+
multiple,
|
|
165
|
+
capture,
|
|
166
|
+
maxSize,
|
|
167
|
+
maxFiles,
|
|
168
|
+
isValid,
|
|
169
|
+
invalidMessage,
|
|
170
|
+
onFiles,
|
|
171
|
+
onReject,
|
|
172
|
+
labels,
|
|
173
|
+
disabled: inert
|
|
174
|
+
});
|
|
175
|
+
const [dragOver, setDragOver] = useState(false);
|
|
176
|
+
const hasFiles = (e) => Array.from(e.dataTransfer?.types ?? []).includes("Files");
|
|
177
|
+
const dropHandlers = droppable ? {
|
|
178
|
+
onDragEnter: (e) => {
|
|
179
|
+
onDragEnter?.(e);
|
|
180
|
+
if (!hasFiles(e)) return;
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
if (!inert) setDragOver(true);
|
|
183
|
+
},
|
|
184
|
+
onDragOver: (e) => {
|
|
185
|
+
onDragOver?.(e);
|
|
186
|
+
if (!hasFiles(e)) return;
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
e.dataTransfer.dropEffect = inert ? "none" : "copy";
|
|
189
|
+
},
|
|
190
|
+
onDragLeave: (e) => {
|
|
191
|
+
onDragLeave?.(e);
|
|
192
|
+
if (e.currentTarget.contains(e.relatedTarget)) return;
|
|
193
|
+
setDragOver(false);
|
|
194
|
+
},
|
|
195
|
+
onDrop: (e) => {
|
|
196
|
+
onDrop?.(e);
|
|
197
|
+
if (!hasFiles(e)) return;
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
setDragOver(false);
|
|
200
|
+
picker.take(e.dataTransfer.files);
|
|
201
|
+
}
|
|
202
|
+
} : { onDragEnter, onDragOver, onDragLeave, onDrop };
|
|
203
|
+
const refProp = { ref };
|
|
204
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
205
|
+
/* @__PURE__ */ jsxs(
|
|
206
|
+
Button,
|
|
207
|
+
{
|
|
208
|
+
...rest,
|
|
209
|
+
...refProp,
|
|
210
|
+
...dropHandlers,
|
|
211
|
+
type: "button",
|
|
212
|
+
disabled: inert,
|
|
213
|
+
"aria-busy": pending || void 0,
|
|
214
|
+
"data-drag-over": dragOver || void 0,
|
|
215
|
+
className: cn(
|
|
216
|
+
// A live drag gets the focus ring's colour as a ring: "let go here", in the
|
|
217
|
+
// same vocabulary the button already uses for "you are here".
|
|
218
|
+
dragOver && "ring-2 ring-[var(--brand)]",
|
|
219
|
+
className
|
|
220
|
+
),
|
|
221
|
+
onClick: (e) => {
|
|
222
|
+
onClick?.(e);
|
|
223
|
+
if (!e.defaultPrevented) picker.open();
|
|
224
|
+
},
|
|
225
|
+
children: [
|
|
226
|
+
pending && /* @__PURE__ */ jsx(Spinner, { label: null, className: "size-4" }),
|
|
227
|
+
children
|
|
228
|
+
]
|
|
229
|
+
}
|
|
230
|
+
),
|
|
231
|
+
picker.element
|
|
232
|
+
] });
|
|
233
|
+
});
|
|
234
|
+
FileButton.displayName = "FileButton";
|
|
235
|
+
export {
|
|
236
|
+
DEFAULT_FILE_PICKER_LABELS,
|
|
237
|
+
FileButton,
|
|
238
|
+
matchesAccept,
|
|
239
|
+
screenFiles,
|
|
240
|
+
summariseRejections,
|
|
241
|
+
useFilePicker
|
|
242
|
+
};
|
|
243
|
+
//# sourceMappingURL=file-button.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/components/file-button.tsx"],"sourcesContent":["import { forwardRef, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, DragEvent, ReactElement, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useKitFileLabels, useKitLabels } from \"../i18n/kit-labels\";\nimport { Button, Spinner } from \"./ui\";\n\n/**\n * A button that opens the file picker — the shape all three apps kept writing by hand\n * as a `<Button>` plus a hidden `<input type=\"file\">` plus a ref between them (seven\n * copies in keksdose, two each in kastlan and lenkbank).\n *\n * Every copy had to remember the same four things, and each one forgot at least one:\n *\n * 1. **Reset the input after every pick.** A file input fires `change` only when its\n * value CHANGES, so picking the same file twice in a row — the retry after a failed\n * upload, the second photo of the same receipt — did nothing at all, which reads as\n * a broken button. `value = \"\"` after each pick; always, not per call site.\n * 2. **`type=\"button\"`.** `<Button>` does not set it, and inside a form a bare button\n * submits the form before the picker opens.\n * 3. **Check the file.** `accept` filters the DIALOG, not the result: the dialog's\n * \"All files\" switch, a drop, and a mobile share sheet all hand over whatever the\n * user chose. So `accept` is re-checked here, along with `maxSize`, `maxFiles` and\n * an optional `isValid`.\n * 4. **Say no without a toast.** A rejection is reported through `onReject`, with a\n * translated message per file, and spoken through a live region — the kit does not\n * decide how an app surfaces errors (keksdose's proposal §1 asked for exactly that).\n *\n * The part that is not a button — the hidden input, the check, the announcement — is\n * {@link useFilePicker}, for the case where the thing that opens the picker is someone\n * else's control (a card's \"Add\" action, a menu item, a second button for the camera).\n */\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\n/** Every string the file pickers ({@link FileButton}, `FileDropzone`) render or speak.\n * Messages are functions of the file name so a translation can put it anywhere. */\nexport interface FilePickerLabels {\n /** The file's type is not in `accept`. */\n rejectedType: (name: string) => string;\n /** The file is larger than `maxSize`; `maxSize` arrives formatted (\"5 MB\"). */\n rejectedSize: (name: string, maxSize: string) => string;\n /** The file was one too many for `maxFiles`. */\n rejectedCount: (name: string, maxFiles: number) => string;\n /** `isValid` said no and the caller gave no `invalidMessage`. */\n rejectedInvalid: (name: string) => string;\n /** Spoken instead of the per-file message when more than one file was refused. */\n rejectedMany: (count: number) => string;\n /** Spoken after a pick the component itself echoes (the dropzone). */\n selected: (count: number, firstName: string) => string;\n /** The dropzone's remove button for one file. */\n remove: (name: string) => string;\n /** The dropzone's remove-everything button in `multiple` mode. */\n clearAll: string;\n /** Spoken after a remove / clear, since the button that was pressed is gone. */\n removed: (name: string) => string;\n cleared: string;\n}\n\nexport const DEFAULT_FILE_PICKER_LABELS: FilePickerLabels = {\n rejectedType: (name) => `“${name}” is not a supported file type`,\n rejectedSize: (name, maxSize) => `“${name}” is larger than ${maxSize}`,\n rejectedCount: (name, maxFiles) =>\n `“${name}” was not added: at most ${maxFiles} ${maxFiles === 1 ? \"file\" : \"files\"}`,\n rejectedInvalid: (name) => `“${name}” cannot be used here`,\n rejectedMany: (count) => `${count} files were not added`,\n selected: (count, firstName) => (count === 1 ? `“${firstName}” selected` : `${count} files selected`),\n remove: (name) => `Remove “${name}”`,\n clearAll: \"Remove all files\",\n removed: (name) => `“${name}” removed`,\n cleared: \"All files removed\",\n};\n\n/* ── Screening ───────────────────────────────────────────────────────────── */\n\nexport type FileRejectionReason = \"type\" | \"size\" | \"count\" | \"invalid\";\n\n/** One file the picker refused, and why. `message` is already translated (see\n * {@link FilePickerLabels}, or the caller's `invalidMessage`), so a host that just\n * wants to show it can render `rejections[0].message` as is. */\nexport interface FileRejection {\n file: File;\n reason: FileRejectionReason;\n message: string;\n}\n\n/**\n * Does `file` satisfy an `accept` string, the way the browser's dialog reads it?\n * Comma-separated tokens: `.ext` (case-insensitive suffix of the name), `type/*`\n * (a MIME family) or an exact MIME type. An empty or absent `accept` takes anything.\n *\n * A file with no `type` — common for `.step`, `.dat`, anything the OS has no MIME\n * mapping for — can only match by extension, which is why lenkbank lists `.stp` AND\n * `model/step`: that is how `accept` has to be written for the dialog anyway.\n */\nexport function matchesAccept(file: File, accept: string | undefined): boolean {\n const tokens = (accept ?? \"\")\n .split(\",\")\n .map((t) => t.trim().toLowerCase())\n .filter(Boolean);\n if (tokens.length === 0) return true;\n const name = file.name.toLowerCase();\n // An EMPTY type is the browser saying \"I don't know\", not \"it's something else\": HEIC\n // photos on Windows without the codec arrive with `type === \"\"`. Refusing them\n // (0.6.0) refused iPhone photos the picker itself had just offered (keksdose). So a\n // missing type is inferred from the extension where that is unambiguous, and a file\n // whose type cannot be known at all is given the benefit of the doubt — nothing here\n // proves it does not match, and the server validates what it receives anyway.\n const type = (file.type || typeFromExtension(name) || \"\").toLowerCase();\n if (!type && !tokens.every((t) => t.startsWith(\".\"))) return true;\n return tokens.some((token) => {\n if (token.startsWith(\".\")) return name.endsWith(token);\n if (!type) return false;\n if (token.endsWith(\"/*\")) return type.startsWith(token.slice(0, -1));\n return type === token;\n });\n}\n\n/** MIME types for the extensions a browser most often leaves untyped. */\nconst EXTENSION_TYPES: Record<string, string> = {\n heic: \"image/heic\",\n heif: \"image/heif\",\n avif: \"image/avif\",\n webp: \"image/webp\",\n jpg: \"image/jpeg\",\n jpeg: \"image/jpeg\",\n png: \"image/png\",\n gif: \"image/gif\",\n pdf: \"application/pdf\",\n csv: \"text/csv\",\n txt: \"text/plain\",\n};\n\nfunction typeFromExtension(name: string): string | undefined {\n const dot = name.lastIndexOf(\".\");\n return dot === -1 ? undefined : EXTENSION_TYPES[name.slice(dot + 1)];\n}\n\n/** What the pickers screen a pick with. All optional; nothing set accepts everything. */\nexport interface FileScreenOptions {\n accept?: string;\n /** Bytes. Larger files are refused with reason `\"size\"`. */\n maxSize?: number;\n /** How many files one pick may deliver. The rest are refused with reason `\"count\"`\n * — first come, first kept. For a running cap (\"at most 5 attachments\") pass what\n * is LEFT: `maxFiles={5 - attachments.length}`. */\n maxFiles?: number;\n /** The caller's own check, after `accept` and `maxSize`. */\n isValid?: (file: File) => boolean;\n /** The message for an `isValid` refusal; `labels.rejectedInvalid` otherwise. */\n invalidMessage?: string;\n}\n\n/** @internal Split a pick into the files that pass and the ones that do not. */\nexport function screenFiles(\n files: readonly File[],\n opts: FileScreenOptions,\n labels: FilePickerLabels,\n formatSize: (bytes: number) => string,\n): { accepted: File[]; rejected: FileRejection[] } {\n const accepted: File[] = [];\n const rejected: FileRejection[] = [];\n for (const file of files) {\n let reason: FileRejectionReason | null = null;\n if (!matchesAccept(file, opts.accept)) reason = \"type\";\n else if (opts.maxSize !== undefined && file.size > opts.maxSize) reason = \"size\";\n else if (opts.isValid && !opts.isValid(file)) reason = \"invalid\";\n // Count last, and only against files that passed everything else: a refused\n // file should not use up one of the slots a good one could have had.\n else if (opts.maxFiles !== undefined && accepted.length >= opts.maxFiles) reason = \"count\";\n if (reason === null) {\n accepted.push(file);\n continue;\n }\n const message =\n reason === \"type\"\n ? labels.rejectedType(file.name)\n : reason === \"size\"\n ? labels.rejectedSize(file.name, formatSize(opts.maxSize ?? 0))\n : reason === \"count\"\n ? labels.rejectedCount(file.name, Math.max(0, opts.maxFiles ?? 0))\n : (opts.invalidMessage ?? labels.rejectedInvalid(file.name));\n rejected.push({ file, reason, message });\n }\n return { accepted, rejected };\n}\n\n/** One sentence for a whole batch of refusals: the file's own message for one, a\n * count for several — reading out five sentences in a row helps nobody. */\nexport function summariseRejections(rejected: readonly FileRejection[], labels: FilePickerLabels): string {\n if (rejected.length === 0) return \"\";\n if (rejected.length === 1) return rejected[0].message;\n return labels.rejectedMany(rejected.length);\n}\n\n/* ── useFilePicker ───────────────────────────────────────────────────────── */\n\nexport interface UseFilePickerOptions extends FileScreenOptions {\n /** Let one pick deliver several files. Without it a drop of several keeps the first. */\n multiple?: boolean;\n /**\n * Ask a phone for its camera instead of the file chooser: `\"environment\"` is the\n * back camera, `\"user\"` the front. A HINT — desktop browsers ignore it, and Chrome\n * drops it when `multiple` is also set (a camera cannot deliver a list), which is why\n * keksdose's camera input has no `multiple`. Pass one or the other.\n */\n capture?: boolean | \"user\" | \"environment\";\n /** The files that passed, in pick order. Never called with an empty array. */\n onFiles: (files: File[]) => void;\n /** The files that did not, with a translated message each. The refusals are also\n * spoken through a live region, so this is for SHOWING them, not for a11y. */\n onReject?: (rejections: FileRejection[]) => void;\n /** `open()` and `take()` do nothing while set. */\n disabled?: boolean;\n /** Per-instance overrides of the `filePicker` label namespace. */\n labels?: Partial<FilePickerLabels>;\n}\n\nexport interface UseFilePickerReturn {\n /** Open the system picker. Call it from a click handler — browsers only open a file\n * dialog in response to a user gesture. */\n open: () => void;\n /** Screen and deliver files that arrived some other way — a drop, a paste. Honours\n * `multiple` (only the first file without it) and every check. */\n take: (files: ArrayLike<File> | null | undefined) => void;\n /** The hidden input and the live region. Render it once, anywhere — it takes no\n * space and needs no positioned ancestor. */\n element: ReactElement;\n}\n\n/**\n * The headless half of {@link FileButton}: a hidden file input you can open from any\n * control, with the reset, the screening and the announcement built in.\n *\n * ```tsx\n * const picker = useFilePicker({ accept: \".pdf\", onFiles: ([f]) => upload(f) });\n * return <>{picker.element}<DetailTableCard onAdd={picker.open} … /></>;\n * ```\n */\nexport function useFilePicker({\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles,\n onReject,\n disabled,\n labels: labelsProp,\n}: UseFilePickerOptions): UseFilePickerReturn {\n const inputRef = useRef<HTMLInputElement>(null);\n const labels = useKitLabels(\"filePicker\", DEFAULT_FILE_PICKER_LABELS, labelsProp);\n const fileText = useKitFileLabels();\n // Assertive: a refusal is a failure the user has to hear before they move on, and\n // it is the only thing this region ever says.\n const { announce, regionProps } = useAnnounce({ politeness: \"assertive\" });\n\n const take = (list: ArrayLike<File> | null | undefined) => {\n if (disabled || !list || list.length === 0) return;\n const all = Array.from(list);\n const files = multiple ? all : all.slice(0, 1);\n const { accepted, rejected } = screenFiles(\n files,\n // A single-file picker holds one file by definition; `maxFiles` is a multi-mode\n // cap and would only ever say \"0\" there if a caller passed it by mistake.\n { accept, maxSize, maxFiles: multiple ? maxFiles : undefined, isValid, invalidMessage },\n labels,\n fileText.size,\n );\n if (accepted.length > 0) onFiles(accepted);\n if (rejected.length > 0) {\n onReject?.(rejected);\n announce(summariseRejections(rejected, labels));\n }\n };\n\n const element = (\n <>\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n capture={capture}\n disabled={disabled}\n // `hidden` (display: none), not `sr-only`: the control a user reaches is the\n // button, and a second, nameless tab stop for the same action is noise. A\n // display:none file input still opens on `.click()` in every current browser —\n // and, unlike `sr-only`, it cannot escape to the initial containing block and\n // stretch the page (see the note in `file-dropzone.tsx`).\n className=\"hidden\"\n tabIndex={-1}\n aria-hidden\n onChange={(e) => {\n // Copy BEFORE the reset: `files` is a live view of the input's value, and\n // clearing the value empties it.\n const picked = Array.from(e.currentTarget.files ?? []);\n e.currentTarget.value = \"\";\n take(picked);\n }}\n />\n <span {...regionProps} />\n </>\n );\n\n return {\n open: () => {\n if (!disabled) inputRef.current?.click();\n },\n take,\n element,\n };\n}\n\n/* ── FileButton ──────────────────────────────────────────────────────────── */\n\ntype ButtonOwnProps = ComponentPropsWithoutRef<typeof Button>;\n\nexport interface FileButtonProps\n extends Omit<UseFilePickerOptions, \"disabled\">,\n // `onDrop` & co. stay the caller's own when `droppable` is off; `type` is fixed to\n // \"button\" (see 2. above) — a file trigger never submits a form.\n Omit<ButtonOwnProps, \"type\" | \"children\" | \"accept\" | \"capture\" | \"multiple\"> {\n /** The button's content — usually an icon and a word. It is the accessible name. */\n children: ReactNode;\n /** Busy: disabled, `aria-busy`, and a spinner before the content — for \"uploading\". */\n pending?: boolean;\n /** Also accept files dropped ON the button (lenkbank's \"drop onto the button\").\n * Off by default: a button that silently takes drops is a surprise on a page that\n * has a real drop target elsewhere. */\n droppable?: boolean;\n}\n\n/**\n * {@link useFilePicker} behind a {@link Button}: `variant` and every other button prop\n * pass through, and the ref is the `<button>` (so `ref.current.click()` opens the\n * picker from elsewhere too).\n *\n * ```tsx\n * <FileButton accept=\"image/*,application/pdf\" capture=\"environment\" variant=\"secondary\"\n * maxSize={10_000_000} onFiles={([f]) => upload(f)} onReject={([r]) => setError(r.message)}>\n * <Camera aria-hidden /> Photograph receipt\n * </FileButton>\n * ```\n */\nexport const FileButton = forwardRef<HTMLButtonElement, FileButtonProps>(function FileButton(\n {\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles,\n onReject,\n labels,\n pending,\n droppable,\n disabled,\n children,\n className,\n onClick,\n onDragEnter,\n onDragOver,\n onDragLeave,\n onDrop,\n ...rest\n },\n ref,\n) {\n const inert = Boolean(disabled || pending);\n const picker = useFilePicker({\n accept,\n multiple,\n capture,\n maxSize,\n maxFiles,\n isValid,\n invalidMessage,\n onFiles,\n onReject,\n labels,\n disabled: inert,\n });\n const [dragOver, setDragOver] = useState(false);\n\n const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes(\"Files\");\n // Only when `droppable`: otherwise the caller's own handlers are passed untouched.\n const dropHandlers = droppable\n ? {\n onDragEnter: (e: DragEvent<HTMLButtonElement>) => {\n onDragEnter?.(e);\n if (!hasFiles(e)) return;\n e.preventDefault();\n if (!inert) setDragOver(true);\n },\n onDragOver: (e: DragEvent<HTMLButtonElement>) => {\n onDragOver?.(e);\n if (!hasFiles(e)) return;\n // Always cancel for a file drag, busy or not: an uncancelled drop makes the\n // browser NAVIGATE to the file, which loses the page.\n e.preventDefault();\n e.dataTransfer.dropEffect = inert ? \"none\" : \"copy\";\n },\n onDragLeave: (e: DragEvent<HTMLButtonElement>) => {\n onDragLeave?.(e);\n if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;\n setDragOver(false);\n },\n onDrop: (e: DragEvent<HTMLButtonElement>) => {\n onDrop?.(e);\n if (!hasFiles(e)) return;\n e.preventDefault();\n setDragOver(false);\n picker.take(e.dataTransfer.files);\n },\n }\n : { onDragEnter, onDragOver, onDragLeave, onDrop };\n\n // `Button` is a plain function component; under React 19 `ref` is an ordinary prop\n // and reaches the `<button>` through its `...rest`. Its props type just does not\n // declare it, hence the widening here rather than a second button implementation.\n const refProp = { ref } as { ref?: Ref<HTMLButtonElement> };\n\n return (\n <>\n <Button\n {...rest}\n {...refProp}\n {...dropHandlers}\n type=\"button\"\n disabled={inert}\n aria-busy={pending || undefined}\n data-drag-over={dragOver || undefined}\n className={cn(\n // A live drag gets the focus ring's colour as a ring: \"let go here\", in the\n // same vocabulary the button already uses for \"you are here\".\n dragOver && \"ring-2 ring-[var(--brand)]\",\n className,\n )}\n onClick={(e) => {\n onClick?.(e);\n if (!e.defaultPrevented) picker.open();\n }}\n >\n {/* `label={null}`: the spinner is decoration here — `aria-busy` says the same\n thing on the button, and a spoken \"Loading\" would run into its name. */}\n {pending && <Spinner label={null} className=\"size-4\" />}\n {children}\n </Button>\n {picker.element}\n </>\n );\n});\nFileButton.displayName = \"FileButton\";\n"],"mappings":"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","names":["FileButton"]}
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { ComponentPropsWithoutRef } from 'react';
|
|
3
|
+
import { FileRejection, FilePickerLabels } from './file-button.js';
|
|
4
|
+
import './ui.js';
|
|
3
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Where a refused file's message goes.
|
|
8
|
+
*
|
|
9
|
+
* - `"toast"` — a `sonner` toast (the 0.5 behaviour, kept as the default for a caller
|
|
10
|
+
* that handles nothing itself so no existing dropzone goes quiet on upgrade).
|
|
11
|
+
* - `"inline"` — under the drop target, in the danger colour, tied to it with
|
|
12
|
+
* `aria-describedby` (plus `data-invalid` for styling), and spoken through a live
|
|
13
|
+
* region. Cleared by the next accepted pick.
|
|
14
|
+
* - `"none"` — nothing visible; the caller shows it from `onReject` / `onInvalid`.
|
|
15
|
+
* Still spoken, so a host rendering it should not ALSO make it `role="alert"`.
|
|
16
|
+
*/
|
|
17
|
+
type FileDropzoneRejectionFeedback = "toast" | "inline" | "none";
|
|
4
18
|
/**
|
|
5
19
|
* `onInvalid` is omitted from the `<div>` attributes and kept as this component's own.
|
|
6
20
|
* The DOM event of that name belongs to form validation and takes a `FormEvent`; this
|
|
@@ -8,30 +22,64 @@ import { ComponentPropsWithoutRef } from 'react';
|
|
|
8
22
|
* writes. Everything else a `<div>` takes reaches the root — which carries
|
|
9
23
|
* `role="button"`, so that is also where an `aria-label` or an `aria-describedby`
|
|
10
24
|
* belongs.
|
|
25
|
+
*
|
|
26
|
+
* Two modes. SINGLE (the default, and all of 0.5): `file` + `onFileSelected`. MULTIPLE
|
|
27
|
+
* (`multiple`): `files` + `onFilesSelected`. They are optional rather than a union so
|
|
28
|
+
* `FileDropzoneProps` stays an interface a caller can extend; a single-mode pick also
|
|
29
|
+
* reaches `onFilesSelected` as a one-element array, so one handler can serve both.
|
|
11
30
|
*/
|
|
12
31
|
interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">, "onInvalid"> {
|
|
13
|
-
|
|
14
|
-
|
|
32
|
+
/** Single mode: the chosen file, echoed with its size. */
|
|
33
|
+
file?: File | null;
|
|
34
|
+
/** Single mode: called with the accepted file. */
|
|
35
|
+
onFileSelected?: (file: File) => void;
|
|
36
|
+
/** Take several files per pick or drop. Without it a drop of several keeps the first. */
|
|
37
|
+
multiple?: boolean;
|
|
38
|
+
/** Multiple mode: the chosen files, listed in the zone. */
|
|
39
|
+
files?: readonly File[];
|
|
40
|
+
/** Every accepted file of one pick, in order. Never called with an empty array. */
|
|
41
|
+
onFilesSelected?: (files: File[]) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Show a remove button inside the zone once something is chosen (kastlan asked for
|
|
44
|
+
* it: the only way to un-choose a file was to reload). Single mode: removes the file.
|
|
45
|
+
* Multiple mode: a "remove all" button. The host clears its own state here — the
|
|
46
|
+
* zone is controlled. Focus returns to the zone, and the removal is announced.
|
|
47
|
+
*/
|
|
48
|
+
onClear?: () => void;
|
|
49
|
+
/** Multiple mode: a remove button per listed file. */
|
|
50
|
+
onRemove?: (file: File, index: number) => void;
|
|
15
51
|
accept: string;
|
|
16
|
-
isValid: (file: File) => boolean;
|
|
17
|
-
invalidMessage: string;
|
|
18
52
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* the
|
|
53
|
+
* The caller's check. Optional since 0.6: without it the zone checks `accept` itself
|
|
54
|
+
* (a drop ignores the dialog's filter). With it, `accept` is NOT re-checked — every
|
|
55
|
+
* 0.5 caller's `isValid` is its own type check, and doubling it could refuse a file
|
|
56
|
+
* the caller accepts (a `.zip` the OS labelled `application/octet-stream`).
|
|
57
|
+
*/
|
|
58
|
+
isValid?: (file: File) => boolean;
|
|
59
|
+
/** The message for an `isValid` refusal. Defaults to `labels.rejectedInvalid`. */
|
|
60
|
+
invalidMessage?: string;
|
|
61
|
+
/** Bytes; larger files are refused. */
|
|
62
|
+
maxSize?: number;
|
|
63
|
+
/** Multiple mode: files one pick may add. Pass what is LEFT for a running cap. */
|
|
64
|
+
maxFiles?: number;
|
|
65
|
+
/**
|
|
66
|
+
* Called once per refused file. The 0.5 hook, kept as it was; {@link onReject}
|
|
67
|
+
* carries the reason and the translated message as well.
|
|
23
68
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* without a FileDropzone anywhere in it. Both current consumers happen to depend
|
|
30
|
-
* on sonner, which is why nothing broke and why the contradiction survived:
|
|
31
|
-
* `use-wizard.ts` had already answered the identical question the other way, in
|
|
32
|
-
* writing, two modules over.
|
|
69
|
+
* Its existence used to be what switched the toast off, and it still does (see
|
|
70
|
+
* {@link rejectionFeedback}). The toast is a dynamic `import("sonner")` on the
|
|
71
|
+
* failure path only: `sonner` is an OPTIONAL peer, and a static import here once
|
|
72
|
+
* broke `import { Button }` for every app without it (see
|
|
73
|
+
* `optional-peer-imports.test.tsx`).
|
|
33
74
|
*/
|
|
34
75
|
onInvalid?: (file: File) => void;
|
|
76
|
+
/** Every refused file of one pick, with its reason and message. */
|
|
77
|
+
onReject?: (rejections: FileRejection[]) => void;
|
|
78
|
+
/** See {@link FileDropzoneRejectionFeedback}. Defaults to `"toast"` when neither
|
|
79
|
+
* `onInvalid` nor `onReject` is given, `"none"` when one is. */
|
|
80
|
+
rejectionFeedback?: FileDropzoneRejectionFeedback;
|
|
81
|
+
/** Per-instance overrides of the `filePicker` label namespace. */
|
|
82
|
+
labels?: Partial<FilePickerLabels>;
|
|
35
83
|
/** The dropzone's accessible name, and the instruction shown on it. A caller's own
|
|
36
84
|
* `aria-label` wins over it — see the root element below. */
|
|
37
85
|
dropLabel: string;
|
|
@@ -41,10 +89,11 @@ interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">, "onInv
|
|
|
41
89
|
/** Extra classes for the dropzone's root. */
|
|
42
90
|
className?: string;
|
|
43
91
|
}
|
|
44
|
-
/** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml)
|
|
45
|
-
*
|
|
46
|
-
* `
|
|
47
|
-
* `hint` describe what to drop.
|
|
48
|
-
|
|
92
|
+
/** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml) and
|
|
93
|
+
* kastlan's document forms. Refusals go to `onReject`/`onInvalid` and to
|
|
94
|
+
* `rejectionFeedback`; the chosen file(s) are echoed with their size, otherwise
|
|
95
|
+
* `emptyLabel` + `hint` describe what to drop. For a trigger with no drop area, see
|
|
96
|
+
* `FileButton`. */
|
|
97
|
+
declare function FileDropzone({ file, onFileSelected, multiple, files, onFilesSelected, onClear, onRemove, accept, isValid, invalidMessage, maxSize, maxFiles, onInvalid, onReject, rejectionFeedback, labels: labelsProp, dropLabel, browseLabel, emptyLabel, hint, className, "aria-label": ariaLabel, "aria-describedby": describedByProp, ...rest }: FileDropzoneProps): react.JSX.Element;
|
|
49
98
|
|
|
50
|
-
export { FileDropzone, type FileDropzoneProps };
|
|
99
|
+
export { FileDropzone, type FileDropzoneProps, type FileDropzoneRejectionFeedback };
|