@eifi1/ui-kit 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -17
- package/dist/chart.d.ts +3 -2
- package/dist/components/alert-banner.d.ts +59 -10
- package/dist/components/alert-banner.js +114 -8
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.d.ts +3 -0
- package/dist/components/button-group.d.ts +26 -0
- package/dist/components/button-group.js +49 -0
- package/dist/components/button-group.js.map +1 -0
- package/dist/components/calculator.d.ts +3 -0
- package/dist/components/chart-zoom.d.ts +63 -14
- package/dist/components/chart-zoom.js +82 -16
- package/dist/components/chart-zoom.js.map +1 -1
- package/dist/components/chip.d.ts +67 -2
- package/dist/components/chip.js +73 -8
- package/dist/components/chip.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +120 -0
- package/dist/components/confirm-dialog.js +96 -0
- package/dist/components/confirm-dialog.js.map +1 -0
- package/dist/components/copy-button.d.ts +62 -0
- package/dist/components/copy-button.js +95 -0
- package/dist/components/copy-button.js.map +1 -0
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table-pagination.d.ts +5 -1
- package/dist/components/data-table-pagination.js +71 -58
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table-sort.d.ts +28 -5
- package/dist/components/data-table-sort.js +11 -7
- package/dist/components/data-table-sort.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +544 -423
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +51 -5
- package/dist/components/date-picker.js +152 -40
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +60 -0
- package/dist/components/description-list.js +112 -0
- package/dist/components/description-list.js.map +1 -0
- package/dist/components/disclosure.d.ts +37 -2
- package/dist/components/disclosure.js +14 -5
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/facing-pair.d.ts +2 -0
- package/dist/components/file-button.d.ts +17 -1
- package/dist/components/file-button.js +8 -1
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +54 -9
- package/dist/components/file-dropzone.js +100 -70
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/floating-panel.d.ts +107 -0
- package/dist/components/floating-panel.js +206 -0
- package/dist/components/floating-panel.js.map +1 -0
- package/dist/components/full-bleed-dialog.d.ts +12 -1
- package/dist/components/full-bleed-dialog.js +26 -5
- package/dist/components/full-bleed-dialog.js.map +1 -1
- package/dist/components/modal.d.ts +6 -2
- package/dist/components/modal.js +2 -1
- package/dist/components/modal.js.map +1 -1
- package/dist/components/number-field.d.ts +3 -0
- package/dist/components/number-input.d.ts +3 -0
- package/dist/components/numpad-sheet.d.ts +3 -0
- package/dist/components/progress-bar.d.ts +54 -0
- package/dist/components/progress-bar.js +94 -0
- package/dist/components/progress-bar.js.map +1 -0
- package/dist/components/scroll-area.d.ts +53 -0
- package/dist/components/scroll-area.js +86 -0
- package/dist/components/scroll-area.js.map +1 -0
- package/dist/components/separator.d.ts +23 -0
- package/dist/components/separator.js +24 -0
- package/dist/components/separator.js.map +1 -0
- package/dist/components/series-chart-ticks.d.ts +34 -1
- package/dist/components/series-chart-ticks.js +83 -1
- package/dist/components/series-chart-ticks.js.map +1 -1
- package/dist/components/series-chart.d.ts +314 -19
- package/dist/components/series-chart.js +510 -120
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/skeleton.d.ts +36 -0
- package/dist/components/skeleton.js +35 -0
- package/dist/components/skeleton.js.map +1 -0
- package/dist/components/stat-tile.d.ts +60 -6
- package/dist/components/stat-tile.js +60 -26
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/table.d.ts +83 -0
- package/dist/components/table.js +166 -0
- package/dist/components/table.js.map +1 -0
- package/dist/components/toggle-legend.d.ts +3 -2
- package/dist/components/toggle-legend.js +1 -1
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/tree-view.d.ts +129 -0
- package/dist/components/tree-view.js +376 -0
- package/dist/components/tree-view.js.map +1 -0
- package/dist/components/treemap.d.ts +9 -2
- package/dist/components/treemap.js +2 -1
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/ui.d.ts +87 -13
- package/dist/components/ui.js +111 -29
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/{data-table-filters-noy0Abvi.d.ts → data-table-filters-CF1PXqjQ.d.ts} +73 -3
- package/dist/data-table.d.ts +2 -2
- package/dist/data-table.js.map +1 -1
- package/dist/hooks/use-copy-to-clipboard.d.ts +28 -0
- package/dist/hooks/use-copy-to-clipboard.js +81 -0
- package/dist/hooks/use-copy-to-clipboard.js.map +1 -0
- package/dist/hooks/use-debounce.d.ts +43 -0
- package/dist/hooks/use-debounce.js +56 -0
- package/dist/hooks/use-debounce.js.map +1 -0
- package/dist/hooks/use-file-drop.d.ts +72 -0
- package/dist/hooks/use-file-drop.js +57 -0
- package/dist/hooks/use-file-drop.js.map +1 -0
- package/dist/i18n/defaults.d.ts +3 -0
- package/dist/i18n/defaults.js +7 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +15 -0
- package/dist/i18n/kit-labels.js +6 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +43 -0
- package/dist/i18n/locales/de-CH-informal.js +8 -0
- package/dist/i18n/locales/de-CH-informal.js.map +1 -0
- package/dist/i18n/locales/de-CH.d.ts +3 -0
- package/dist/i18n/locales/de-CH.js +1 -14
- package/dist/i18n/locales/de-CH.js.map +1 -1
- package/dist/i18n/locales/de-informal.d.ts +64 -0
- package/dist/i18n/locales/de-informal.js +37 -0
- package/dist/i18n/locales/de-informal.js.map +1 -0
- package/dist/i18n/locales/de.d.ts +3 -0
- package/dist/i18n/locales/de.js +25 -0
- package/dist/i18n/locales/de.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +3 -0
- package/dist/i18n/locales/es.js +25 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +3 -0
- package/dist/i18n/locales/fr.js +25 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +3 -0
- package/dist/i18n/locales/hu.js +25 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +3 -0
- package/dist/i18n/locales/it.js +25 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +3 -0
- package/dist/i18n/locales/zh.js +25 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/swiss.d.ts +13 -0
- package/dist/i18n/swiss.js +19 -0
- package/dist/i18n/swiss.js.map +1 -0
- package/dist/index.d.ts +25 -10
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/dates.d.ts +37 -1
- package/dist/lib/dates.js +19 -0
- package/dist/lib/dates.js.map +1 -1
- package/dist/search/command-palette.d.ts +15 -1
- package/dist/search/command-palette.js +10 -2
- package/dist/search/command-palette.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +35 -2
- package/dist/wizard/stepper-nav.js +54 -22
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.d.ts +107 -5
- package/dist/wizard/types.js +2 -1
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +141 -21
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-summary.d.ts +10 -2
- package/dist/wizard/wizard-summary.js +3 -1
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/dist/wizard.d.ts +4 -1
- package/dist/wizard.js.map +1 -1
- package/package.json +1 -1
- package/src/components/alert-banner.tsx +211 -20
- package/src/components/button-group.tsx +75 -0
- package/src/components/chart-zoom.tsx +158 -20
- package/src/components/chip.tsx +160 -9
- package/src/components/confirm-dialog.tsx +242 -0
- package/src/components/copy-button.tsx +158 -0
- package/src/components/data-table-pagination.tsx +19 -6
- package/src/components/data-table-sort.ts +49 -10
- package/src/components/data-table.tsx +293 -24
- package/src/components/date-picker.tsx +262 -57
- package/src/components/description-list.tsx +174 -0
- package/src/components/disclosure.tsx +58 -5
- package/src/components/file-button.tsx +22 -2
- package/src/components/file-dropzone.tsx +198 -100
- package/src/components/floating-panel.tsx +341 -0
- package/src/components/full-bleed-dialog.tsx +48 -5
- package/src/components/modal.tsx +9 -2
- package/src/components/progress-bar.tsx +163 -0
- package/src/components/scroll-area.tsx +129 -0
- package/src/components/separator.tsx +37 -0
- package/src/components/series-chart-ticks.ts +135 -0
- package/src/components/series-chart.tsx +934 -67
- package/src/components/skeleton.tsx +65 -0
- package/src/components/stat-tile.tsx +160 -27
- package/src/components/table.tsx +263 -0
- package/src/components/toggle-legend.tsx +4 -3
- package/src/components/tree-view.tsx +589 -0
- package/src/components/treemap.tsx +10 -2
- package/src/components/ui.tsx +263 -39
- package/src/data-table.ts +2 -0
- package/src/hooks/use-copy-to-clipboard.ts +122 -0
- package/src/hooks/use-debounce.ts +105 -0
- package/src/hooks/use-file-drop.ts +123 -0
- package/src/i18n/defaults.ts +6 -0
- package/src/i18n/kit-labels.tsx +19 -0
- package/src/i18n/locales/de-CH-informal.ts +10 -0
- package/src/i18n/locales/de-CH.ts +1 -16
- package/src/i18n/locales/de-informal.ts +61 -0
- package/src/i18n/locales/de.ts +25 -0
- package/src/i18n/locales/es.ts +25 -0
- package/src/i18n/locales/fr.ts +25 -0
- package/src/i18n/locales/hu.ts +25 -0
- package/src/i18n/locales/it.ts +25 -0
- package/src/i18n/locales/zh.ts +25 -0
- package/src/i18n/swiss.ts +25 -0
- package/src/index.ts +29 -0
- package/src/lib/dates.ts +48 -0
- package/src/search/command-palette.tsx +29 -3
- package/src/wizard/stepper-nav.tsx +102 -28
- package/src/wizard/types.ts +106 -4
- package/src/wizard/use-wizard.ts +211 -31
- package/src/wizard/wizard-summary.tsx +24 -12
- package/src/wizard.ts +3 -2
|
@@ -64,6 +64,19 @@ export interface FilePickerLabels {
|
|
|
64
64
|
/** Spoken after a remove / clear, since the button that was pressed is gone. */
|
|
65
65
|
removed: (name: string) => string;
|
|
66
66
|
cleared: string;
|
|
67
|
+
/** The dropzone's accessible name, when the host passes no `dropLabel`. */
|
|
68
|
+
dropzone: string;
|
|
69
|
+
/** The dropzone's Browse button, when the host passes no `browseLabel`. */
|
|
70
|
+
browse: string;
|
|
71
|
+
/** What an empty single-file dropzone says (no `emptyLabel` given). */
|
|
72
|
+
empty: string;
|
|
73
|
+
/** …and an empty `multiple` one. */
|
|
74
|
+
emptyMultiple: string;
|
|
75
|
+
/** The dropzone's second line (no `hint` given). Receives `accept` as a person
|
|
76
|
+
* reads it (`".pdf, image/png"`), or `""` when anything goes. */
|
|
77
|
+
hint: (accept: string) => string;
|
|
78
|
+
/** What a `busy` dropzone says in place of its body ("Uploading…"). */
|
|
79
|
+
busy: string;
|
|
67
80
|
}
|
|
68
81
|
|
|
69
82
|
export const DEFAULT_FILE_PICKER_LABELS: FilePickerLabels = {
|
|
@@ -81,6 +94,12 @@ export const DEFAULT_FILE_PICKER_LABELS: FilePickerLabels = {
|
|
|
81
94
|
clearAll: "Remove all files",
|
|
82
95
|
removed: (name) => `“${name}” removed`,
|
|
83
96
|
cleared: "All files removed",
|
|
97
|
+
dropzone: "File upload",
|
|
98
|
+
browse: "Browse",
|
|
99
|
+
empty: "Drop a file here",
|
|
100
|
+
emptyMultiple: "Drop files here",
|
|
101
|
+
hint: (accept) => (accept ? `Accepted: ${accept}` : "Any file type"),
|
|
102
|
+
busy: "Uploading…",
|
|
84
103
|
};
|
|
85
104
|
|
|
86
105
|
/* ── Screening ───────────────────────────────────────────────────────────── */
|
|
@@ -148,8 +167,9 @@ function typeFromExtension(name: string): string | undefined {
|
|
|
148
167
|
return dot === -1 ? undefined : EXTENSION_TYPES[name.slice(dot + 1)];
|
|
149
168
|
}
|
|
150
169
|
|
|
151
|
-
/** `" .PDF, image/png ,"` => `".pdf, image/png"` — `accept` as a person reads
|
|
152
|
-
|
|
170
|
+
/** @internal `" .PDF, image/png ,"` => `".pdf, image/png"` — `accept` as a person reads
|
|
171
|
+
* it. Also the dropzone's default hint. */
|
|
172
|
+
export function formatAccept(accept: string): string {
|
|
153
173
|
return accept
|
|
154
174
|
.split(",")
|
|
155
175
|
.map((t) => t.trim().toLowerCase())
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { useId, useRef, useState } from "react";
|
|
2
|
-
import type { ComponentPropsWithoutRef } from "react";
|
|
2
|
+
import type { ComponentPropsWithoutRef, MouseEvent, ReactNode } from "react";
|
|
3
3
|
import { Upload, X } from "lucide-react";
|
|
4
|
-
import { Button, IconButton } from "./ui";
|
|
4
|
+
import { Button, IconButton, Spinner } from "./ui";
|
|
5
5
|
import { cn } from "../lib/cn";
|
|
6
6
|
import { useAnnounce } from "../hooks/use-announce";
|
|
7
7
|
import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_FILE_PICKER_LABELS,
|
|
10
|
+
formatAccept,
|
|
11
|
+
judgePick,
|
|
12
|
+
screenFiles,
|
|
13
|
+
summariseRejections,
|
|
14
|
+
} from "./file-button";
|
|
9
15
|
import type { FilePickHandler, FilePickerLabels, FileRejection } from "./file-button";
|
|
16
|
+
import { useDragTarget } from "../hooks/use-file-drop";
|
|
10
17
|
|
|
11
18
|
/**
|
|
12
19
|
* Where a refused file's message goes.
|
|
@@ -21,6 +28,23 @@ import type { FilePickHandler, FilePickerLabels, FileRejection } from "./file-bu
|
|
|
21
28
|
*/
|
|
22
29
|
export type FileDropzoneRejectionFeedback = "toast" | "inline" | "none";
|
|
23
30
|
|
|
31
|
+
/** What a custom {@link FileDropzoneProps.renderBody} is handed. */
|
|
32
|
+
export interface FileDropzoneState {
|
|
33
|
+
/** A file drag is over the zone right now. */
|
|
34
|
+
dragOver: boolean;
|
|
35
|
+
disabled: boolean;
|
|
36
|
+
busy: boolean;
|
|
37
|
+
/** What is chosen: `files` in multiple mode, `[file]` or `[]` in single mode. */
|
|
38
|
+
files: readonly File[];
|
|
39
|
+
/** The inline refusal, when `rejectionFeedback="inline"` has one to show. */
|
|
40
|
+
error: string | null;
|
|
41
|
+
/** Open the system picker — for a body that brings its own trigger (a second
|
|
42
|
+
* "Take photo" button). Does nothing while `disabled` or `busy`. */
|
|
43
|
+
open: () => void;
|
|
44
|
+
/** The resolved `filePicker` strings, so a custom body speaks the same language. */
|
|
45
|
+
labels: FilePickerLabels;
|
|
46
|
+
}
|
|
47
|
+
|
|
24
48
|
/**
|
|
25
49
|
* `onInvalid` is omitted from the `<div>` attributes and kept as this component's own.
|
|
26
50
|
* The DOM event of that name belongs to form validation and takes a `FormEvent`; this
|
|
@@ -90,12 +114,41 @@ export interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">,
|
|
|
90
114
|
rejectionFeedback?: FileDropzoneRejectionFeedback;
|
|
91
115
|
/** Per-instance overrides of the `filePicker` label namespace. */
|
|
92
116
|
labels?: Partial<FilePickerLabels>;
|
|
93
|
-
/** The dropzone's accessible name
|
|
94
|
-
*
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
117
|
+
/** The dropzone's accessible name. A caller's own `aria-label` wins over it — see
|
|
118
|
+
* the root element below. Optional since 0.8, like the three below: each falls back
|
|
119
|
+
* to its `filePicker` string from `<UiKitProvider labels>`, then to English. They
|
|
120
|
+
* were required, so every call site passed four strings through `t()` by hand, and a
|
|
121
|
+
* zone nested in a kit component (the feedback form) had no call site to pass them. */
|
|
122
|
+
dropLabel?: string;
|
|
123
|
+
/** Default `filePicker.browse`. */
|
|
124
|
+
browseLabel?: string;
|
|
125
|
+
/** What the zone says while nothing is chosen. Default `filePicker.empty` /
|
|
126
|
+
* `emptyMultiple`. */
|
|
127
|
+
emptyLabel?: string;
|
|
128
|
+
/** The second line, e.g. what may be dropped. Default `filePicker.hint(accept)`. */
|
|
129
|
+
hint?: string;
|
|
130
|
+
/**
|
|
131
|
+
* Not taking files: no drop, no picker, no remove buttons; `aria-disabled` on the
|
|
132
|
+
* group. keksdose locks its invoice zone while another form holds unsaved changes,
|
|
133
|
+
* and hand-rolled the whole zone to be able to (invoice-upload.tsx, "Locked").
|
|
134
|
+
*/
|
|
135
|
+
disabled?: boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Working on what it was given — an upload, an OCR pass. Inert like `disabled`, plus
|
|
138
|
+
* `aria-busy`, a spinner in place of the upload icon, and `filePicker.busy` as the
|
|
139
|
+
* text. The other half of why keksdose's invoice zone was hand-rolled.
|
|
140
|
+
*/
|
|
141
|
+
busy?: boolean;
|
|
142
|
+
/**
|
|
143
|
+
* Replace the zone's body — the icon, the text and the chosen-file list — with your
|
|
144
|
+
* own, given the zone's state: keksdose's spinner-plus-"uploading", its multi-shot
|
|
145
|
+
* hint ("several photos are possible"), a second trigger for the camera.
|
|
146
|
+
*
|
|
147
|
+
* The Browse button, the inline error and the live regions stay: Browse is the
|
|
148
|
+
* keyboard path, and a body that forgot it would leave the zone unreachable without
|
|
149
|
+
* a pointer. A click on a control inside the body does not also open the picker.
|
|
150
|
+
*/
|
|
151
|
+
renderBody?: (state: FileDropzoneState) => ReactNode;
|
|
99
152
|
/** Extra classes for the dropzone's root. */
|
|
100
153
|
className?: string;
|
|
101
154
|
}
|
|
@@ -123,18 +176,23 @@ export function FileDropzone({
|
|
|
123
176
|
onPick,
|
|
124
177
|
rejectionFeedback,
|
|
125
178
|
labels: labelsProp,
|
|
126
|
-
dropLabel,
|
|
127
|
-
browseLabel,
|
|
128
|
-
emptyLabel,
|
|
129
|
-
hint,
|
|
179
|
+
dropLabel: dropLabelProp,
|
|
180
|
+
browseLabel: browseLabelProp,
|
|
181
|
+
emptyLabel: emptyLabelProp,
|
|
182
|
+
hint: hintProp,
|
|
183
|
+
disabled,
|
|
184
|
+
busy,
|
|
185
|
+
renderBody,
|
|
130
186
|
className,
|
|
131
187
|
"aria-label": ariaLabel,
|
|
132
188
|
"aria-describedby": describedByProp,
|
|
133
189
|
...rest
|
|
134
190
|
}: FileDropzoneProps) {
|
|
135
|
-
const [dragOver, setDragOver] = useState(false);
|
|
136
191
|
const [error, setError] = useState<string | null>(null);
|
|
137
|
-
const
|
|
192
|
+
const inert = Boolean(disabled || busy);
|
|
193
|
+
// State rather than a ref: `open` is handed to `renderBody` during render, and a
|
|
194
|
+
// function that reads a ref there is exactly what the refs rule cannot prove safe.
|
|
195
|
+
const [fileInput, setFileInput] = useState<HTMLInputElement | null>(null);
|
|
138
196
|
const browseRef = useRef<HTMLButtonElement>(null);
|
|
139
197
|
const errorId = useId();
|
|
140
198
|
// The size through the kit's `file.size` label (the provider's locale by default).
|
|
@@ -149,9 +207,13 @@ export function FileDropzone({
|
|
|
149
207
|
const alert = useAnnounce({ politeness: "assertive" });
|
|
150
208
|
const feedback: FileDropzoneRejectionFeedback =
|
|
151
209
|
rejectionFeedback ?? (onInvalid || onReject ? "none" : "toast");
|
|
210
|
+
const dropLabel = dropLabelProp ?? labels.dropzone;
|
|
211
|
+
const browseLabel = browseLabelProp ?? labels.browse;
|
|
212
|
+
const emptyLabel = emptyLabelProp ?? (multiple ? labels.emptyMultiple : labels.empty);
|
|
213
|
+
const hint = hintProp ?? labels.hint(formatAccept(accept));
|
|
152
214
|
|
|
153
215
|
const acceptFiles = (list: ArrayLike<File> | null | undefined) => {
|
|
154
|
-
if (!list || list.length === 0) return;
|
|
216
|
+
if (inert || !list || list.length === 0) return;
|
|
155
217
|
const all = Array.from(list);
|
|
156
218
|
const picked = multiple ? all : all.slice(0, 1);
|
|
157
219
|
const { accepted, rejected } = screenFiles(
|
|
@@ -204,6 +266,17 @@ export function FileDropzone({
|
|
|
204
266
|
status.announce(message);
|
|
205
267
|
};
|
|
206
268
|
|
|
269
|
+
// The enter/leave counter lives in the shared hook: the boolean this was flickered
|
|
270
|
+
// across every child of the zone in Safari, whose drag events carry no
|
|
271
|
+
// `relatedTarget` to tell a move onto a child from a real leave.
|
|
272
|
+
const { dropProps, isOver: dragOver } = useDragTarget<HTMLDivElement>({
|
|
273
|
+
disabled: inert,
|
|
274
|
+
onDropFiles: acceptFiles,
|
|
275
|
+
});
|
|
276
|
+
const open = () => {
|
|
277
|
+
if (!inert) fileInput?.click();
|
|
278
|
+
};
|
|
279
|
+
|
|
207
280
|
const chosen: readonly File[] = multiple ? (files ?? []) : file ? [file] : [];
|
|
208
281
|
const showError = feedback === "inline" && error !== null;
|
|
209
282
|
const describedBy = cn(describedByProp, showError && errorId) || undefined;
|
|
@@ -221,32 +294,14 @@ export function FileDropzone({
|
|
|
221
294
|
<div
|
|
222
295
|
// `...rest` first: every handler below is the drop gesture itself.
|
|
223
296
|
{...rest}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
e.stopPropagation();
|
|
232
|
-
e.dataTransfer.dropEffect = "copy";
|
|
233
|
-
if (!dragOver && e.dataTransfer.types.includes("Files")) setDragOver(true);
|
|
234
|
-
}}
|
|
235
|
-
onDragLeave={(e) => {
|
|
236
|
-
e.preventDefault();
|
|
237
|
-
e.stopPropagation();
|
|
238
|
-
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
|
|
239
|
-
setDragOver(false);
|
|
297
|
+
{...dropProps}
|
|
298
|
+
// Pointer only; every control inside stops its own click from reaching here
|
|
299
|
+
// (and one in a custom body is skipped below). The keyboard equivalent is the
|
|
300
|
+
// Browse button, so no key handler belongs on a group.
|
|
301
|
+
onClick={(e) => {
|
|
302
|
+
if (fromControl(e)) return;
|
|
303
|
+
open();
|
|
240
304
|
}}
|
|
241
|
-
onDrop={(e) => {
|
|
242
|
-
e.preventDefault();
|
|
243
|
-
e.stopPropagation();
|
|
244
|
-
setDragOver(false);
|
|
245
|
-
acceptFiles(e.dataTransfer.files);
|
|
246
|
-
}}
|
|
247
|
-
// Pointer only; every control inside stops its own click from reaching here. The
|
|
248
|
-
// keyboard equivalent is the Browse button, so no key handler belongs on a group.
|
|
249
|
-
onClick={() => fileInputRef.current?.click()}
|
|
250
305
|
role="group"
|
|
251
306
|
// The DOM spelling wins over `dropLabel`, which stays the name for every caller
|
|
252
307
|
// that passes no `aria-label` — i.e. all of them today. Standardising on
|
|
@@ -257,6 +312,9 @@ export function FileDropzone({
|
|
|
257
312
|
// reached through the description instead, and spoken when it appears.
|
|
258
313
|
aria-describedby={describedBy}
|
|
259
314
|
data-invalid={showError || undefined}
|
|
315
|
+
aria-disabled={disabled || undefined}
|
|
316
|
+
aria-busy={busy || undefined}
|
|
317
|
+
data-drag-over={dragOver || undefined}
|
|
260
318
|
className={cn(
|
|
261
319
|
// `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,
|
|
262
320
|
// which Tailwind implements as `position: absolute` — so without a positioned
|
|
@@ -267,73 +325,102 @@ export function FileDropzone({
|
|
|
267
325
|
// own offset, which produces a second, whole-document scrollbar alongside the
|
|
268
326
|
// app shell's own — one that scrolls past the end of the content into nothing.
|
|
269
327
|
// Measured on the showcase: body 900px, document 47,919px.
|
|
270
|
-
"relative flex
|
|
328
|
+
"relative flex flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors",
|
|
271
329
|
// Three states out of two border tokens: the target rests on the plain
|
|
272
330
|
// hairline, hover pulls it to `--border-strong`, and a live drag keeps that
|
|
273
331
|
// border and adds the `--bg-active` wash on top — so "let go here" still reads
|
|
274
332
|
// one step louder than "you are over it". A refusal shown inline takes the
|
|
275
333
|
// danger border until the next good pick.
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
334
|
+
// Inert zones keep the resting hairline and lose the hover pull: a border that
|
|
335
|
+
// answers the pointer promises a drop the zone will refuse.
|
|
336
|
+
disabled
|
|
337
|
+
? "cursor-not-allowed border-[var(--border)] opacity-60"
|
|
338
|
+
: busy
|
|
339
|
+
? "cursor-progress border-[var(--border)]"
|
|
340
|
+
: dragOver
|
|
341
|
+
? "cursor-pointer border-[var(--border-strong)] bg-[var(--bg-active)]"
|
|
342
|
+
: showError
|
|
343
|
+
? "cursor-pointer border-[var(--danger-border)]"
|
|
344
|
+
: "cursor-pointer border-[var(--border)] hover:border-[var(--border-strong)]",
|
|
281
345
|
className,
|
|
282
346
|
)}
|
|
283
347
|
>
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
onClick={(e) => {
|
|
302
|
-
e.stopPropagation();
|
|
303
|
-
onRemove(f, i);
|
|
304
|
-
afterRemoval(labels.removed(f.name));
|
|
305
|
-
}}
|
|
306
|
-
>
|
|
307
|
-
<X aria-hidden />
|
|
308
|
-
</IconButton>
|
|
309
|
-
)}
|
|
310
|
-
</li>
|
|
311
|
-
))}
|
|
312
|
-
</ul>
|
|
348
|
+
{renderBody ? (
|
|
349
|
+
renderBody({
|
|
350
|
+
dragOver,
|
|
351
|
+
disabled: Boolean(disabled),
|
|
352
|
+
busy: Boolean(busy),
|
|
353
|
+
files: chosen,
|
|
354
|
+
error: showError ? error : null,
|
|
355
|
+
open,
|
|
356
|
+
labels,
|
|
357
|
+
})
|
|
358
|
+
) : busy ? (
|
|
359
|
+
<>
|
|
360
|
+
{/* `label={null}`: the zone is `aria-busy` and the line below says the
|
|
361
|
+
same in words; a spoken "Loading" would only run into it. */}
|
|
362
|
+
<Spinner label={null} className="size-5" />
|
|
363
|
+
<div className="text-sm text-[var(--text-secondary)]">{labels.busy}</div>
|
|
364
|
+
</>
|
|
313
365
|
) : (
|
|
314
366
|
<>
|
|
315
|
-
<
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
367
|
+
<Upload aria-hidden className="size-5 text-[var(--text-muted)]" />
|
|
368
|
+
{multiple && chosen.length > 0 ? (
|
|
369
|
+
<ul className="flex w-full max-w-sm flex-col gap-1 text-start">
|
|
370
|
+
{chosen.map((f, i) => (
|
|
371
|
+
<li
|
|
372
|
+
// Name + size + position: two files may share a name, and the index
|
|
373
|
+
// alone would re-key every row below a removal.
|
|
374
|
+
key={`${f.name}-${f.size}-${i}`}
|
|
375
|
+
className="flex items-center gap-2 text-sm text-[var(--text-secondary)]"
|
|
376
|
+
>
|
|
377
|
+
<span className="min-w-0 flex-1 truncate font-medium">{f.name}</span>
|
|
378
|
+
<span className="shrink-0 text-xs text-[var(--text-muted)]">{fileText.size(f.size)}</span>
|
|
379
|
+
{onRemove && (
|
|
380
|
+
<IconButton
|
|
381
|
+
type="button"
|
|
382
|
+
size="sm"
|
|
383
|
+
disabled={inert}
|
|
384
|
+
aria-label={labels.remove(f.name)}
|
|
385
|
+
onClick={(e) => {
|
|
386
|
+
e.stopPropagation();
|
|
387
|
+
onRemove(f, i);
|
|
388
|
+
afterRemoval(labels.removed(f.name));
|
|
389
|
+
}}
|
|
390
|
+
>
|
|
391
|
+
<X aria-hidden />
|
|
392
|
+
</IconButton>
|
|
393
|
+
)}
|
|
394
|
+
</li>
|
|
395
|
+
))}
|
|
396
|
+
</ul>
|
|
397
|
+
) : (
|
|
398
|
+
<>
|
|
399
|
+
<div className="flex items-center gap-1 text-sm text-[var(--text-secondary)]">
|
|
400
|
+
{/* `!multiple`: in multiple mode an empty `files` list is "nothing chosen",
|
|
401
|
+
whatever a stray `file` prop says — it used to show that file's name. */}
|
|
402
|
+
{!multiple && file ? <span className="font-medium">{file.name}</span> : emptyLabel}
|
|
403
|
+
{!multiple && file && onClear && (
|
|
404
|
+
<IconButton
|
|
405
|
+
type="button"
|
|
406
|
+
size="sm"
|
|
407
|
+
disabled={inert}
|
|
408
|
+
aria-label={labels.remove(file.name)}
|
|
409
|
+
onClick={(e) => {
|
|
410
|
+
e.stopPropagation();
|
|
411
|
+
onClear();
|
|
412
|
+
afterRemoval(labels.removed(file.name));
|
|
413
|
+
}}
|
|
414
|
+
>
|
|
415
|
+
<X aria-hidden />
|
|
416
|
+
</IconButton>
|
|
417
|
+
)}
|
|
418
|
+
</div>
|
|
419
|
+
<div className="text-xs text-[var(--text-muted)]">
|
|
420
|
+
{file && !multiple ? fileText.size(file.size) : hint}
|
|
421
|
+
</div>
|
|
422
|
+
</>
|
|
423
|
+
)}
|
|
337
424
|
</>
|
|
338
425
|
)}
|
|
339
426
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
|
@@ -341,9 +428,10 @@ export function FileDropzone({
|
|
|
341
428
|
ref={browseRef}
|
|
342
429
|
type="button"
|
|
343
430
|
variant="secondary"
|
|
431
|
+
disabled={inert}
|
|
344
432
|
onClick={(e) => {
|
|
345
433
|
e.stopPropagation();
|
|
346
|
-
|
|
434
|
+
open();
|
|
347
435
|
}}
|
|
348
436
|
>
|
|
349
437
|
{browseLabel}
|
|
@@ -352,6 +440,7 @@ export function FileDropzone({
|
|
|
352
440
|
<Button
|
|
353
441
|
type="button"
|
|
354
442
|
variant="ghost"
|
|
443
|
+
disabled={inert}
|
|
355
444
|
onClick={(e) => {
|
|
356
445
|
e.stopPropagation();
|
|
357
446
|
onClear();
|
|
@@ -368,10 +457,11 @@ export function FileDropzone({
|
|
|
368
457
|
</p>
|
|
369
458
|
)}
|
|
370
459
|
<input
|
|
371
|
-
ref={
|
|
460
|
+
ref={setFileInput}
|
|
372
461
|
type="file"
|
|
373
462
|
accept={accept}
|
|
374
463
|
multiple={multiple}
|
|
464
|
+
disabled={inert}
|
|
375
465
|
onChange={(e) => {
|
|
376
466
|
// Copy, THEN reset: `files` is a live view of the value, and without the
|
|
377
467
|
// reset picking the same file a second time fires no `change` at all —
|
|
@@ -394,3 +484,11 @@ export function FileDropzone({
|
|
|
394
484
|
</>
|
|
395
485
|
);
|
|
396
486
|
}
|
|
487
|
+
|
|
488
|
+
/** A click that landed on a control inside the zone (a custom body's own button, a
|
|
489
|
+
* link in the hint) is that control's, not a request to open the picker. */
|
|
490
|
+
function fromControl(e: MouseEvent<HTMLElement>): boolean {
|
|
491
|
+
const target = e.target as Element | null;
|
|
492
|
+
const control = target?.closest?.("button, a[href], input, select, textarea, label, [role='button']");
|
|
493
|
+
return Boolean(control && control !== e.currentTarget && e.currentTarget.contains(control));
|
|
494
|
+
}
|