@eifi1/ui-kit 0.7.1 → 0.8.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 +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 +28 -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 +6 -2
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/ui.d.ts +94 -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 +53 -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 +13 -2
- package/src/components/ui.tsx +270 -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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
|
-
import { ComponentPropsWithoutRef } from 'react';
|
|
2
|
+
import { ComponentPropsWithoutRef, ReactNode } from 'react';
|
|
3
3
|
import { FileRejection, FilePickHandler, FilePickerLabels } from './file-button.js';
|
|
4
4
|
import './ui.js';
|
|
5
5
|
import './tooltip.js';
|
|
@@ -17,6 +17,22 @@ import '../hooks/use-anchored-rect.js';
|
|
|
17
17
|
* Still spoken, so a host rendering it should not ALSO make it `role="alert"`.
|
|
18
18
|
*/
|
|
19
19
|
type FileDropzoneRejectionFeedback = "toast" | "inline" | "none";
|
|
20
|
+
/** What a custom {@link FileDropzoneProps.renderBody} is handed. */
|
|
21
|
+
interface FileDropzoneState {
|
|
22
|
+
/** A file drag is over the zone right now. */
|
|
23
|
+
dragOver: boolean;
|
|
24
|
+
disabled: boolean;
|
|
25
|
+
busy: boolean;
|
|
26
|
+
/** What is chosen: `files` in multiple mode, `[file]` or `[]` in single mode. */
|
|
27
|
+
files: readonly File[];
|
|
28
|
+
/** The inline refusal, when `rejectionFeedback="inline"` has one to show. */
|
|
29
|
+
error: string | null;
|
|
30
|
+
/** Open the system picker — for a body that brings its own trigger (a second
|
|
31
|
+
* "Take photo" button). Does nothing while `disabled` or `busy`. */
|
|
32
|
+
open: () => void;
|
|
33
|
+
/** The resolved `filePicker` strings, so a custom body speaks the same language. */
|
|
34
|
+
labels: FilePickerLabels;
|
|
35
|
+
}
|
|
20
36
|
/**
|
|
21
37
|
* `onInvalid` is omitted from the `<div>` attributes and kept as this component's own.
|
|
22
38
|
* The DOM event of that name belongs to form validation and takes a `FormEvent`; this
|
|
@@ -86,12 +102,41 @@ interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">, "onInv
|
|
|
86
102
|
rejectionFeedback?: FileDropzoneRejectionFeedback;
|
|
87
103
|
/** Per-instance overrides of the `filePicker` label namespace. */
|
|
88
104
|
labels?: Partial<FilePickerLabels>;
|
|
89
|
-
/** The dropzone's accessible name
|
|
90
|
-
*
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
105
|
+
/** The dropzone's accessible name. A caller's own `aria-label` wins over it — see
|
|
106
|
+
* the root element below. Optional since 0.8, like the three below: each falls back
|
|
107
|
+
* to its `filePicker` string from `<UiKitProvider labels>`, then to English. They
|
|
108
|
+
* were required, so every call site passed four strings through `t()` by hand, and a
|
|
109
|
+
* zone nested in a kit component (the feedback form) had no call site to pass them. */
|
|
110
|
+
dropLabel?: string;
|
|
111
|
+
/** Default `filePicker.browse`. */
|
|
112
|
+
browseLabel?: string;
|
|
113
|
+
/** What the zone says while nothing is chosen. Default `filePicker.empty` /
|
|
114
|
+
* `emptyMultiple`. */
|
|
115
|
+
emptyLabel?: string;
|
|
116
|
+
/** The second line, e.g. what may be dropped. Default `filePicker.hint(accept)`. */
|
|
117
|
+
hint?: string;
|
|
118
|
+
/**
|
|
119
|
+
* Not taking files: no drop, no picker, no remove buttons; `aria-disabled` on the
|
|
120
|
+
* group. keksdose locks its invoice zone while another form holds unsaved changes,
|
|
121
|
+
* and hand-rolled the whole zone to be able to (invoice-upload.tsx, "Locked").
|
|
122
|
+
*/
|
|
123
|
+
disabled?: boolean;
|
|
124
|
+
/**
|
|
125
|
+
* Working on what it was given — an upload, an OCR pass. Inert like `disabled`, plus
|
|
126
|
+
* `aria-busy`, a spinner in place of the upload icon, and `filePicker.busy` as the
|
|
127
|
+
* text. The other half of why keksdose's invoice zone was hand-rolled.
|
|
128
|
+
*/
|
|
129
|
+
busy?: boolean;
|
|
130
|
+
/**
|
|
131
|
+
* Replace the zone's body — the icon, the text and the chosen-file list — with your
|
|
132
|
+
* own, given the zone's state: keksdose's spinner-plus-"uploading", its multi-shot
|
|
133
|
+
* hint ("several photos are possible"), a second trigger for the camera.
|
|
134
|
+
*
|
|
135
|
+
* The Browse button, the inline error and the live regions stay: Browse is the
|
|
136
|
+
* keyboard path, and a body that forgot it would leave the zone unreachable without
|
|
137
|
+
* a pointer. A click on a control inside the body does not also open the picker.
|
|
138
|
+
*/
|
|
139
|
+
renderBody?: (state: FileDropzoneState) => ReactNode;
|
|
95
140
|
/** Extra classes for the dropzone's root. */
|
|
96
141
|
className?: string;
|
|
97
142
|
}
|
|
@@ -100,6 +145,6 @@ interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">, "onInv
|
|
|
100
145
|
* `rejectionFeedback`; the chosen file(s) are echoed with their size, otherwise
|
|
101
146
|
* `emptyLabel` + `hint` describe what to drop. For a trigger with no drop area, see
|
|
102
147
|
* `FileButton`. */
|
|
103
|
-
declare function FileDropzone({ file, onFileSelected, multiple, files, onFilesSelected, onClear, onRemove, accept, isValid, invalidMessage, maxSize, maxFiles, onInvalid, onReject, onPick, rejectionFeedback, labels: labelsProp, dropLabel, browseLabel, emptyLabel, hint, className, "aria-label": ariaLabel, "aria-describedby": describedByProp, ...rest }: FileDropzoneProps): react.JSX.Element;
|
|
148
|
+
declare function FileDropzone({ file, onFileSelected, multiple, files, onFilesSelected, onClear, onRemove, accept, isValid, invalidMessage, maxSize, maxFiles, onInvalid, onReject, onPick, rejectionFeedback, labels: labelsProp, dropLabel: dropLabelProp, browseLabel: browseLabelProp, emptyLabel: emptyLabelProp, hint: hintProp, disabled, busy, renderBody, className, "aria-label": ariaLabel, "aria-describedby": describedByProp, ...rest }: FileDropzoneProps): react.JSX.Element;
|
|
104
149
|
|
|
105
|
-
export { FileDropzone, type FileDropzoneProps, type FileDropzoneRejectionFeedback };
|
|
150
|
+
export { FileDropzone, type FileDropzoneProps, type FileDropzoneRejectionFeedback, type FileDropzoneState };
|
|
@@ -2,11 +2,18 @@
|
|
|
2
2
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useId, useRef, useState } from "react";
|
|
4
4
|
import { Upload, X } from "lucide-react";
|
|
5
|
-
import { Button, IconButton } from "./ui.js";
|
|
5
|
+
import { Button, IconButton, Spinner } from "./ui.js";
|
|
6
6
|
import { cn } from "../lib/cn.js";
|
|
7
7
|
import { useAnnounce } from "../hooks/use-announce.js";
|
|
8
8
|
import { useKitFileLabels, useKitLabels } from "../i18n/kit-labels.js";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_FILE_PICKER_LABELS,
|
|
11
|
+
formatAccept,
|
|
12
|
+
judgePick,
|
|
13
|
+
screenFiles,
|
|
14
|
+
summariseRejections
|
|
15
|
+
} from "./file-button.js";
|
|
16
|
+
import { useDragTarget } from "../hooks/use-file-drop.js";
|
|
10
17
|
function FileDropzone({
|
|
11
18
|
file,
|
|
12
19
|
onFileSelected,
|
|
@@ -25,18 +32,21 @@ function FileDropzone({
|
|
|
25
32
|
onPick,
|
|
26
33
|
rejectionFeedback,
|
|
27
34
|
labels: labelsProp,
|
|
28
|
-
dropLabel,
|
|
29
|
-
browseLabel,
|
|
30
|
-
emptyLabel,
|
|
31
|
-
hint,
|
|
35
|
+
dropLabel: dropLabelProp,
|
|
36
|
+
browseLabel: browseLabelProp,
|
|
37
|
+
emptyLabel: emptyLabelProp,
|
|
38
|
+
hint: hintProp,
|
|
39
|
+
disabled,
|
|
40
|
+
busy,
|
|
41
|
+
renderBody,
|
|
32
42
|
className,
|
|
33
43
|
"aria-label": ariaLabel,
|
|
34
44
|
"aria-describedby": describedByProp,
|
|
35
45
|
...rest
|
|
36
46
|
}) {
|
|
37
|
-
const [dragOver, setDragOver] = useState(false);
|
|
38
47
|
const [error, setError] = useState(null);
|
|
39
|
-
const
|
|
48
|
+
const inert = Boolean(disabled || busy);
|
|
49
|
+
const [fileInput, setFileInput] = useState(null);
|
|
40
50
|
const browseRef = useRef(null);
|
|
41
51
|
const errorId = useId();
|
|
42
52
|
const fileText = useKitFileLabels();
|
|
@@ -44,8 +54,12 @@ function FileDropzone({
|
|
|
44
54
|
const status = useAnnounce();
|
|
45
55
|
const alert = useAnnounce({ politeness: "assertive" });
|
|
46
56
|
const feedback = rejectionFeedback ?? (onInvalid || onReject ? "none" : "toast");
|
|
57
|
+
const dropLabel = dropLabelProp ?? labels.dropzone;
|
|
58
|
+
const browseLabel = browseLabelProp ?? labels.browse;
|
|
59
|
+
const emptyLabel = emptyLabelProp ?? (multiple ? labels.emptyMultiple : labels.empty);
|
|
60
|
+
const hint = hintProp ?? labels.hint(formatAccept(accept));
|
|
47
61
|
const acceptFiles = (list) => {
|
|
48
|
-
if (!list || list.length === 0) return;
|
|
62
|
+
if (inert || !list || list.length === 0) return;
|
|
49
63
|
const all = Array.from(list);
|
|
50
64
|
const picked = multiple ? all : all.slice(0, 1);
|
|
51
65
|
const { accepted, rejected } = screenFiles(
|
|
@@ -87,6 +101,13 @@ function FileDropzone({
|
|
|
87
101
|
browseRef.current?.focus();
|
|
88
102
|
status.announce(message);
|
|
89
103
|
};
|
|
104
|
+
const { dropProps, isOver: dragOver } = useDragTarget({
|
|
105
|
+
disabled: inert,
|
|
106
|
+
onDropFiles: acceptFiles
|
|
107
|
+
});
|
|
108
|
+
const open = () => {
|
|
109
|
+
if (!inert) fileInput?.click();
|
|
110
|
+
};
|
|
90
111
|
const chosen = multiple ? files ?? [] : file ? [file] : [];
|
|
91
112
|
const showError = feedback === "inline" && error !== null;
|
|
92
113
|
const describedBy = cn(describedByProp, showError && errorId) || void 0;
|
|
@@ -95,34 +116,18 @@ function FileDropzone({
|
|
|
95
116
|
"div",
|
|
96
117
|
{
|
|
97
118
|
...rest,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
e
|
|
101
|
-
|
|
102
|
-
},
|
|
103
|
-
onDragOver: (e) => {
|
|
104
|
-
e.preventDefault();
|
|
105
|
-
e.stopPropagation();
|
|
106
|
-
e.dataTransfer.dropEffect = "copy";
|
|
107
|
-
if (!dragOver && e.dataTransfer.types.includes("Files")) setDragOver(true);
|
|
108
|
-
},
|
|
109
|
-
onDragLeave: (e) => {
|
|
110
|
-
e.preventDefault();
|
|
111
|
-
e.stopPropagation();
|
|
112
|
-
if (e.currentTarget.contains(e.relatedTarget)) return;
|
|
113
|
-
setDragOver(false);
|
|
114
|
-
},
|
|
115
|
-
onDrop: (e) => {
|
|
116
|
-
e.preventDefault();
|
|
117
|
-
e.stopPropagation();
|
|
118
|
-
setDragOver(false);
|
|
119
|
-
acceptFiles(e.dataTransfer.files);
|
|
119
|
+
...dropProps,
|
|
120
|
+
onClick: (e) => {
|
|
121
|
+
if (fromControl(e)) return;
|
|
122
|
+
open();
|
|
120
123
|
},
|
|
121
|
-
onClick: () => fileInputRef.current?.click(),
|
|
122
124
|
role: "group",
|
|
123
125
|
"aria-label": ariaLabel ?? dropLabel,
|
|
124
126
|
"aria-describedby": describedBy,
|
|
125
127
|
"data-invalid": showError || void 0,
|
|
128
|
+
"aria-disabled": disabled || void 0,
|
|
129
|
+
"aria-busy": busy || void 0,
|
|
130
|
+
"data-drag-over": dragOver || void 0,
|
|
126
131
|
className: cn(
|
|
127
132
|
// `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,
|
|
128
133
|
// which Tailwind implements as `position: absolute` — so without a positioned
|
|
@@ -133,60 +138,77 @@ function FileDropzone({
|
|
|
133
138
|
// own offset, which produces a second, whole-document scrollbar alongside the
|
|
134
139
|
// app shell's own — one that scrolls past the end of the content into nothing.
|
|
135
140
|
// Measured on the showcase: body 900px, document 47,919px.
|
|
136
|
-
"relative flex
|
|
141
|
+
"relative flex flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors",
|
|
137
142
|
// Three states out of two border tokens: the target rests on the plain
|
|
138
143
|
// hairline, hover pulls it to `--border-strong`, and a live drag keeps that
|
|
139
144
|
// border and adds the `--bg-active` wash on top — so "let go here" still reads
|
|
140
145
|
// one step louder than "you are over it". A refusal shown inline takes the
|
|
141
146
|
// danger border until the next good pick.
|
|
142
|
-
|
|
147
|
+
// Inert zones keep the resting hairline and lose the hover pull: a border that
|
|
148
|
+
// answers the pointer promises a drop the zone will refuse.
|
|
149
|
+
disabled ? "cursor-not-allowed border-[var(--border)] opacity-60" : busy ? "cursor-progress border-[var(--border)]" : dragOver ? "cursor-pointer border-[var(--border-strong)] bg-[var(--bg-active)]" : showError ? "cursor-pointer border-[var(--danger-border)]" : "cursor-pointer border-[var(--border)] hover:border-[var(--border-strong)]",
|
|
143
150
|
className
|
|
144
151
|
),
|
|
145
152
|
children: [
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
renderBody ? renderBody({
|
|
154
|
+
dragOver,
|
|
155
|
+
disabled: Boolean(disabled),
|
|
156
|
+
busy: Boolean(busy),
|
|
157
|
+
files: chosen,
|
|
158
|
+
error: showError ? error : null,
|
|
159
|
+
open,
|
|
160
|
+
labels
|
|
161
|
+
}) : busy ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
162
|
+
/* @__PURE__ */ jsx(Spinner, { label: null, className: "size-5" }),
|
|
163
|
+
/* @__PURE__ */ jsx("div", { className: "text-sm text-[var(--text-secondary)]", children: labels.busy })
|
|
164
|
+
] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
165
|
+
/* @__PURE__ */ jsx(Upload, { "aria-hidden": true, className: "size-5 text-[var(--text-muted)]" }),
|
|
166
|
+
multiple && chosen.length > 0 ? /* @__PURE__ */ jsx("ul", { className: "flex w-full max-w-sm flex-col gap-1 text-start", children: chosen.map((f, i) => /* @__PURE__ */ jsxs(
|
|
167
|
+
"li",
|
|
168
|
+
{
|
|
169
|
+
className: "flex items-center gap-2 text-sm text-[var(--text-secondary)]",
|
|
170
|
+
children: [
|
|
171
|
+
/* @__PURE__ */ jsx("span", { className: "min-w-0 flex-1 truncate font-medium", children: f.name }),
|
|
172
|
+
/* @__PURE__ */ jsx("span", { className: "shrink-0 text-xs text-[var(--text-muted)]", children: fileText.size(f.size) }),
|
|
173
|
+
onRemove && /* @__PURE__ */ jsx(
|
|
174
|
+
IconButton,
|
|
175
|
+
{
|
|
176
|
+
type: "button",
|
|
177
|
+
size: "sm",
|
|
178
|
+
disabled: inert,
|
|
179
|
+
"aria-label": labels.remove(f.name),
|
|
180
|
+
onClick: (e) => {
|
|
181
|
+
e.stopPropagation();
|
|
182
|
+
onRemove(f, i);
|
|
183
|
+
afterRemoval(labels.removed(f.name));
|
|
184
|
+
},
|
|
185
|
+
children: /* @__PURE__ */ jsx(X, { "aria-hidden": true })
|
|
186
|
+
}
|
|
187
|
+
)
|
|
188
|
+
]
|
|
189
|
+
},
|
|
190
|
+
`${f.name}-${f.size}-${i}`
|
|
191
|
+
)) }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
192
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1 text-sm text-[var(--text-secondary)]", children: [
|
|
193
|
+
!multiple && file ? /* @__PURE__ */ jsx("span", { className: "font-medium", children: file.name }) : emptyLabel,
|
|
194
|
+
!multiple && file && onClear && /* @__PURE__ */ jsx(
|
|
155
195
|
IconButton,
|
|
156
196
|
{
|
|
157
197
|
type: "button",
|
|
158
198
|
size: "sm",
|
|
159
|
-
|
|
199
|
+
disabled: inert,
|
|
200
|
+
"aria-label": labels.remove(file.name),
|
|
160
201
|
onClick: (e) => {
|
|
161
202
|
e.stopPropagation();
|
|
162
|
-
|
|
163
|
-
afterRemoval(labels.removed(
|
|
203
|
+
onClear();
|
|
204
|
+
afterRemoval(labels.removed(file.name));
|
|
164
205
|
},
|
|
165
206
|
children: /* @__PURE__ */ jsx(X, { "aria-hidden": true })
|
|
166
207
|
}
|
|
167
208
|
)
|
|
168
|
-
]
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
)) }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
172
|
-
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1 text-sm text-[var(--text-secondary)]", children: [
|
|
173
|
-
!multiple && file ? /* @__PURE__ */ jsx("span", { className: "font-medium", children: file.name }) : emptyLabel,
|
|
174
|
-
!multiple && file && onClear && /* @__PURE__ */ jsx(
|
|
175
|
-
IconButton,
|
|
176
|
-
{
|
|
177
|
-
type: "button",
|
|
178
|
-
size: "sm",
|
|
179
|
-
"aria-label": labels.remove(file.name),
|
|
180
|
-
onClick: (e) => {
|
|
181
|
-
e.stopPropagation();
|
|
182
|
-
onClear();
|
|
183
|
-
afterRemoval(labels.removed(file.name));
|
|
184
|
-
},
|
|
185
|
-
children: /* @__PURE__ */ jsx(X, { "aria-hidden": true })
|
|
186
|
-
}
|
|
187
|
-
)
|
|
188
|
-
] }),
|
|
189
|
-
/* @__PURE__ */ jsx("div", { className: "text-xs text-[var(--text-muted)]", children: file && !multiple ? fileText.size(file.size) : hint })
|
|
209
|
+
] }),
|
|
210
|
+
/* @__PURE__ */ jsx("div", { className: "text-xs text-[var(--text-muted)]", children: file && !multiple ? fileText.size(file.size) : hint })
|
|
211
|
+
] })
|
|
190
212
|
] }),
|
|
191
213
|
/* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-center gap-2", children: [
|
|
192
214
|
/* @__PURE__ */ jsx(
|
|
@@ -195,9 +217,10 @@ function FileDropzone({
|
|
|
195
217
|
ref: browseRef,
|
|
196
218
|
type: "button",
|
|
197
219
|
variant: "secondary",
|
|
220
|
+
disabled: inert,
|
|
198
221
|
onClick: (e) => {
|
|
199
222
|
e.stopPropagation();
|
|
200
|
-
|
|
223
|
+
open();
|
|
201
224
|
},
|
|
202
225
|
children: browseLabel
|
|
203
226
|
}
|
|
@@ -207,6 +230,7 @@ function FileDropzone({
|
|
|
207
230
|
{
|
|
208
231
|
type: "button",
|
|
209
232
|
variant: "ghost",
|
|
233
|
+
disabled: inert,
|
|
210
234
|
onClick: (e) => {
|
|
211
235
|
e.stopPropagation();
|
|
212
236
|
onClear();
|
|
@@ -220,10 +244,11 @@ function FileDropzone({
|
|
|
220
244
|
/* @__PURE__ */ jsx(
|
|
221
245
|
"input",
|
|
222
246
|
{
|
|
223
|
-
ref:
|
|
247
|
+
ref: setFileInput,
|
|
224
248
|
type: "file",
|
|
225
249
|
accept,
|
|
226
250
|
multiple,
|
|
251
|
+
disabled: inert,
|
|
227
252
|
onChange: (e) => {
|
|
228
253
|
const picked = Array.from(e.currentTarget.files ?? []);
|
|
229
254
|
e.currentTarget.value = "";
|
|
@@ -240,6 +265,11 @@ function FileDropzone({
|
|
|
240
265
|
/* @__PURE__ */ jsx("span", { ...alert.regionProps })
|
|
241
266
|
] });
|
|
242
267
|
}
|
|
268
|
+
function fromControl(e) {
|
|
269
|
+
const target = e.target;
|
|
270
|
+
const control = target?.closest?.("button, a[href], input, select, textarea, label, [role='button']");
|
|
271
|
+
return Boolean(control && control !== e.currentTarget && e.currentTarget.contains(control));
|
|
272
|
+
}
|
|
243
273
|
export {
|
|
244
274
|
FileDropzone
|
|
245
275
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/components/file-dropzone.tsx"],"sourcesContent":["import { useId, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef } from \"react\";\nimport { Upload, X } from \"lucide-react\";\nimport { Button, IconButton } from \"./ui\";\nimport { cn } from \"../lib/cn\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useKitFileLabels, useKitLabels } from \"../i18n/kit-labels\";\nimport { DEFAULT_FILE_PICKER_LABELS, judgePick, screenFiles, summariseRejections } from \"./file-button\";\nimport type { FilePickHandler, FilePickerLabels, FileRejection } from \"./file-button\";\n\n/**\n * Where a refused file's message goes.\n *\n * - `\"toast\"` — a `sonner` toast (the 0.5 behaviour, kept as the default for a caller\n * that handles nothing itself so no existing dropzone goes quiet on upgrade).\n * - `\"inline\"` — under the drop target, in the danger colour, tied to it with\n * `aria-describedby` (plus `data-invalid` for styling), and spoken through a live\n * region. Cleared by the next accepted pick.\n * - `\"none\"` — nothing visible; the caller shows it from `onReject` / `onInvalid`.\n * Still spoken, so a host rendering it should not ALSO make it `role=\"alert\"`.\n */\nexport type FileDropzoneRejectionFeedback = \"toast\" | \"inline\" | \"none\";\n\n/**\n * `onInvalid` is omitted from the `<div>` attributes and kept as this component's own.\n * The DOM event of that name belongs to form validation and takes a `FormEvent`; this\n * one takes the rejected `File`, and the kit's meaning is the one every caller already\n * writes. Everything else a `<div>` takes reaches the root — which carries\n * `role=\"group\"` named by `dropLabel`, so that is also where an `aria-label` or an\n * `aria-describedby` belongs.\n *\n * Two modes. SINGLE (the default, and all of 0.5): `file` + `onFileSelected`. MULTIPLE\n * (`multiple`): `files` + `onFilesSelected`. They are optional rather than a union so\n * `FileDropzoneProps` stays an interface a caller can extend; a single-mode pick also\n * reaches `onFilesSelected` as a one-element array, so one handler can serve both.\n */\nexport interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"onInvalid\"> {\n /** Single mode: the chosen file, echoed with its size. */\n file?: File | null;\n /** Single mode: called with the accepted file. */\n onFileSelected?: (file: File) => void;\n /** Take several files per pick or drop. Without it a drop of several keeps the first. */\n multiple?: boolean;\n /** Multiple mode: the chosen files, listed in the zone. */\n files?: readonly File[];\n /** Every accepted file of one pick, in order. Never called with an empty array. */\n onFilesSelected?: (files: File[]) => void;\n /**\n * Show a remove button inside the zone once something is chosen (kastlan asked for\n * it: the only way to un-choose a file was to reload). Single mode: removes the file.\n * Multiple mode: a \"remove all\" button. The host clears its own state here — the\n * zone is controlled. Focus moves to the Browse button, and the removal is announced.\n */\n onClear?: () => void;\n /** Multiple mode: a remove button per listed file. */\n onRemove?: (file: File, index: number) => void;\n accept: string;\n /**\n * The caller's check. Optional since 0.6: without it the zone checks `accept` itself\n * (a drop ignores the dialog's filter). With it, `accept` is NOT re-checked — every\n * 0.5 caller's `isValid` is its own type check, and doubling it could refuse a file\n * the caller accepts (a `.zip` the OS labelled `application/octet-stream`).\n */\n isValid?: (file: File) => boolean;\n /** The message for an `isValid` refusal. Defaults to `labels.rejectedInvalid`. */\n invalidMessage?: string;\n /** Bytes; larger files are refused. */\n maxSize?: number;\n /** Multiple mode: files one pick may add. Pass what is LEFT for a running cap. */\n maxFiles?: number;\n /**\n * Called once per refused file. The 0.5 hook, kept as it was; {@link onReject}\n * carries the reason and the translated message as well.\n *\n * Its existence used to be what switched the toast off, and it still does (see\n * {@link rejectionFeedback}). The toast is a dynamic `import(\"sonner\")` on the\n * failure path only: `sonner` is an OPTIONAL peer, and a static import here once\n * broke `import { Button }` for every app without it (see\n * `optional-peer-imports.test.tsx`).\n */\n onInvalid?: (file: File) => void;\n /** Every refused file of one pick, with its reason and message. */\n onReject?: (rejections: FileRejection[]) => void;\n /** The whole pick at once, before any of the above — return `false` to refuse all\n * of it (see {@link FilePickHandler}). A refused pick selects nothing; its one\n * message (`labels.rejectedPick`) goes where `rejectionFeedback` says. */\n onPick?: FilePickHandler;\n /** See {@link FileDropzoneRejectionFeedback}. Defaults to `\"toast\"` when neither\n * `onInvalid` nor `onReject` is given, `\"none\"` when one is. */\n rejectionFeedback?: FileDropzoneRejectionFeedback;\n /** Per-instance overrides of the `filePicker` label namespace. */\n labels?: Partial<FilePickerLabels>;\n /** The dropzone's accessible name, and the instruction shown on it. A caller's own\n * `aria-label` wins over it — see the root element below. */\n dropLabel: string;\n browseLabel: string;\n emptyLabel: string;\n hint: string;\n /** Extra classes for the dropzone's root. */\n className?: string;\n}\n\n/** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml) and\n * kastlan's document forms. Refusals go to `onReject`/`onInvalid` and to\n * `rejectionFeedback`; the chosen file(s) are echoed with their size, otherwise\n * `emptyLabel` + `hint` describe what to drop. For a trigger with no drop area, see\n * `FileButton`. */\nexport function FileDropzone({\n file,\n onFileSelected,\n multiple,\n files,\n onFilesSelected,\n onClear,\n onRemove,\n accept,\n isValid,\n invalidMessage,\n maxSize,\n maxFiles,\n onInvalid,\n onReject,\n onPick,\n rejectionFeedback,\n labels: labelsProp,\n dropLabel,\n browseLabel,\n emptyLabel,\n hint,\n className,\n \"aria-label\": ariaLabel,\n \"aria-describedby\": describedByProp,\n ...rest\n}: FileDropzoneProps) {\n const [dragOver, setDragOver] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const fileInputRef = useRef<HTMLInputElement>(null);\n const browseRef = useRef<HTMLButtonElement>(null);\n const errorId = useId();\n // The size through the kit's `file.size` label (the provider's locale by default).\n // This was `${n / 1024} KB`: ASCII digits, an English unit, and a binary kilobyte\n // mislabelled as a decimal one, all in one template literal.\n const fileText = useKitFileLabels();\n const labels = useKitLabels(\"filePicker\", DEFAULT_FILE_PICKER_LABELS, labelsProp);\n // Two regions, two urgencies: a pick or a removal is news, a refusal is a failure.\n // The echoed file name is only text in the zone, and text changing somewhere on the\n // page is not announced by itself.\n const status = useAnnounce();\n const alert = useAnnounce({ politeness: \"assertive\" });\n const feedback: FileDropzoneRejectionFeedback =\n rejectionFeedback ?? (onInvalid || onReject ? \"none\" : \"toast\");\n\n const acceptFiles = (list: ArrayLike<File> | null | undefined) => {\n if (!list || list.length === 0) return;\n const all = Array.from(list);\n const picked = multiple ? all : all.slice(0, 1);\n const { accepted, rejected } = screenFiles(\n picked,\n {\n accept: isValid ? undefined : accept,\n maxSize,\n maxFiles: multiple ? maxFiles : undefined,\n isValid,\n invalidMessage,\n },\n labels,\n fileText.size,\n );\n const stands = judgePick(onPick, accepted, rejected);\n if (stands && accepted.length > 0) {\n setError(null);\n if (multiple) {\n onFilesSelected?.(accepted);\n } else {\n onFileSelected?.(accepted[0]);\n onFilesSelected?.(accepted);\n }\n status.announce(labels.selected(accepted.length, accepted[0].name));\n }\n if (!stands || rejected.length > 0) {\n for (const r of rejected) onInvalid?.(r.file);\n if (rejected.length > 0) onReject?.(rejected);\n const message = stands\n ? summariseRejections(rejected, labels)\n : labels.rejectedPick(picked.length);\n if (feedback === \"toast\") {\n // sonner is an optional peer: imported here, on the failure path only, so an\n // app that never trips this never has to install it. Not awaited — nothing\n // downstream depends on the toast having appeared, and the handlers that\n // reach this are DOM events. sonner's own toaster is a live region, so this\n // path does not announce a second time.\n void import(\"sonner\").then(({ toast }) => toast.error(message));\n } else {\n if (feedback === \"inline\") setError(message);\n alert.announce(message);\n }\n }\n };\n\n // The remove buttons unmount with the file they removed, and focus would fall to\n // <body>. Browse is where the next action (pick again) starts.\n const afterRemoval = (message: string) => {\n browseRef.current?.focus();\n status.announce(message);\n };\n\n const chosen: readonly File[] = multiple ? (files ?? []) : file ? [file] : [];\n const showError = feedback === \"inline\" && error !== null;\n const describedBy = cn(describedByProp, showError && errorId) || undefined;\n\n return (\n <>\n {/* A named group, NOT a `role=\"button\"`. It was one, with real buttons (Browse,\n the remove buttons) inside it: nested interactive controls, which ARIA forbids\n because a button's children are presentational — a screen reader either read\n the zone as one opaque button or skipped the inner ones, and the zone was an\n extra Tab stop that did what Browse does. Now the keyboard path is Browse, a\n real button; the zone keeps its drop target and its click-anywhere for a\n pointer, which is a convenience on top of Browse rather than the only way in. */}\n {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions -- the click is a pointer shortcut for Browse, see above */}\n <div\n // `...rest` first: every handler below is the drop gesture itself.\n {...rest}\n onDragEnter={(e) => {\n e.preventDefault();\n e.stopPropagation();\n if (e.dataTransfer.types.includes(\"Files\")) setDragOver(true);\n }}\n onDragOver={(e) => {\n e.preventDefault();\n e.stopPropagation();\n e.dataTransfer.dropEffect = \"copy\";\n if (!dragOver && e.dataTransfer.types.includes(\"Files\")) setDragOver(true);\n }}\n onDragLeave={(e) => {\n e.preventDefault();\n e.stopPropagation();\n if (e.currentTarget.contains(e.relatedTarget as Node)) return;\n setDragOver(false);\n }}\n onDrop={(e) => {\n e.preventDefault();\n e.stopPropagation();\n setDragOver(false);\n acceptFiles(e.dataTransfer.files);\n }}\n // Pointer only; every control inside stops its own click from reaching here. The\n // keyboard equivalent is the Browse button, so no key handler belongs on a group.\n onClick={() => fileInputRef.current?.click()}\n role=\"group\"\n // The DOM spelling wins over `dropLabel`, which stays the name for every caller\n // that passes no `aria-label` — i.e. all of them today. Standardising on\n // `aria-label` (audit §api-design) does not get to silently rename an existing\n // required prop.\n aria-label={ariaLabel ?? dropLabel}\n // No `aria-invalid`: ARIA does not allow it on `role=\"group\"`. The message is\n // reached through the description instead, and spoken when it appears.\n aria-describedby={describedBy}\n data-invalid={showError || undefined}\n className={cn(\n // `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,\n // which Tailwind implements as `position: absolute` — so without a positioned\n // ancestor its containing block is the INITIAL one, and it is laid out at its\n // own offset from the top of the document. Nothing looks wrong, because the\n // input is 1x1 and clipped; what breaks is the page height. On a long page\n // every escaped sr-only element extends `documentElement.scrollHeight` to its\n // own offset, which produces a second, whole-document scrollbar alongside the\n // app shell's own — one that scrolls past the end of the content into nothing.\n // Measured on the showcase: body 900px, document 47,919px.\n \"relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors\",\n // Three states out of two border tokens: the target rests on the plain\n // hairline, hover pulls it to `--border-strong`, and a live drag keeps that\n // border and adds the `--bg-active` wash on top — so \"let go here\" still reads\n // one step louder than \"you are over it\". A refusal shown inline takes the\n // danger border until the next good pick.\n dragOver\n ? \"border-[var(--border-strong)] bg-[var(--bg-active)]\"\n : showError\n ? \"border-[var(--danger-border)]\"\n : \"border-[var(--border)] hover:border-[var(--border-strong)]\",\n className,\n )}\n >\n <Upload aria-hidden className=\"size-5 text-[var(--text-muted)]\" />\n {multiple && chosen.length > 0 ? (\n <ul className=\"flex w-full max-w-sm flex-col gap-1 text-start\">\n {chosen.map((f, i) => (\n <li\n // Name + size + position: two files may share a name, and the index\n // alone would re-key every row below a removal.\n key={`${f.name}-${f.size}-${i}`}\n className=\"flex items-center gap-2 text-sm text-[var(--text-secondary)]\"\n >\n <span className=\"min-w-0 flex-1 truncate font-medium\">{f.name}</span>\n <span className=\"shrink-0 text-xs text-[var(--text-muted)]\">{fileText.size(f.size)}</span>\n {onRemove && (\n <IconButton\n type=\"button\"\n size=\"sm\"\n aria-label={labels.remove(f.name)}\n onClick={(e) => {\n e.stopPropagation();\n onRemove(f, i);\n afterRemoval(labels.removed(f.name));\n }}\n >\n <X aria-hidden />\n </IconButton>\n )}\n </li>\n ))}\n </ul>\n ) : (\n <>\n <div className=\"flex items-center gap-1 text-sm text-[var(--text-secondary)]\">\n {/* `!multiple`: in multiple mode an empty `files` list is \"nothing chosen\",\n whatever a stray `file` prop says — it used to show that file's name. */}\n {!multiple && file ? <span className=\"font-medium\">{file.name}</span> : emptyLabel}\n {!multiple && file && onClear && (\n <IconButton\n type=\"button\"\n size=\"sm\"\n aria-label={labels.remove(file.name)}\n onClick={(e) => {\n e.stopPropagation();\n onClear();\n afterRemoval(labels.removed(file.name));\n }}\n >\n <X aria-hidden />\n </IconButton>\n )}\n </div>\n <div className=\"text-xs text-[var(--text-muted)]\">\n {file && !multiple ? fileText.size(file.size) : hint}\n </div>\n </>\n )}\n <div className=\"flex flex-wrap items-center justify-center gap-2\">\n <Button\n ref={browseRef}\n type=\"button\"\n variant=\"secondary\"\n onClick={(e) => {\n e.stopPropagation();\n fileInputRef.current?.click();\n }}\n >\n {browseLabel}\n </Button>\n {multiple && chosen.length > 0 && onClear && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n onClick={(e) => {\n e.stopPropagation();\n onClear();\n afterRemoval(labels.cleared);\n }}\n >\n {labels.clearAll}\n </Button>\n )}\n </div>\n {showError && (\n <p id={errorId} className=\"text-xs text-[var(--danger)]\">\n {error}\n </p>\n )}\n <input\n ref={fileInputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n onChange={(e) => {\n // Copy, THEN reset: `files` is a live view of the value, and without the\n // reset picking the same file a second time fires no `change` at all —\n // the retry after a failed upload silently did nothing (kastlan).\n const picked = Array.from(e.currentTarget.files ?? []);\n e.currentTarget.value = \"\";\n acceptFiles(picked);\n }}\n // Clicks are forwarded here programmatically; one reaching the zone's own\n // handler again would open the dialog a second time.\n onClick={(e) => e.stopPropagation()}\n className=\"sr-only\"\n />\n </div>\n {/* Outside the zone, where they have always been (the zone used to be a\n `role=\"button\"`, whose content is presentational). `sr-only-fixed`, so they\n need no positioned ancestor. */}\n <span {...status.regionProps} />\n <span {...alert.regionProps} />\n </>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/components/file-dropzone.tsx"],"sourcesContent":["import { useId, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, MouseEvent, ReactNode } from \"react\";\nimport { Upload, X } from \"lucide-react\";\nimport { Button, IconButton, Spinner } from \"./ui\";\nimport { cn } from \"../lib/cn\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { useKitFileLabels, useKitLabels } from \"../i18n/kit-labels\";\nimport {\n DEFAULT_FILE_PICKER_LABELS,\n formatAccept,\n judgePick,\n screenFiles,\n summariseRejections,\n} from \"./file-button\";\nimport type { FilePickHandler, FilePickerLabels, FileRejection } from \"./file-button\";\nimport { useDragTarget } from \"../hooks/use-file-drop\";\n\n/**\n * Where a refused file's message goes.\n *\n * - `\"toast\"` — a `sonner` toast (the 0.5 behaviour, kept as the default for a caller\n * that handles nothing itself so no existing dropzone goes quiet on upgrade).\n * - `\"inline\"` — under the drop target, in the danger colour, tied to it with\n * `aria-describedby` (plus `data-invalid` for styling), and spoken through a live\n * region. Cleared by the next accepted pick.\n * - `\"none\"` — nothing visible; the caller shows it from `onReject` / `onInvalid`.\n * Still spoken, so a host rendering it should not ALSO make it `role=\"alert\"`.\n */\nexport type FileDropzoneRejectionFeedback = \"toast\" | \"inline\" | \"none\";\n\n/** What a custom {@link FileDropzoneProps.renderBody} is handed. */\nexport interface FileDropzoneState {\n /** A file drag is over the zone right now. */\n dragOver: boolean;\n disabled: boolean;\n busy: boolean;\n /** What is chosen: `files` in multiple mode, `[file]` or `[]` in single mode. */\n files: readonly File[];\n /** The inline refusal, when `rejectionFeedback=\"inline\"` has one to show. */\n error: string | null;\n /** Open the system picker — for a body that brings its own trigger (a second\n * \"Take photo\" button). Does nothing while `disabled` or `busy`. */\n open: () => void;\n /** The resolved `filePicker` strings, so a custom body speaks the same language. */\n labels: FilePickerLabels;\n}\n\n/**\n * `onInvalid` is omitted from the `<div>` attributes and kept as this component's own.\n * The DOM event of that name belongs to form validation and takes a `FormEvent`; this\n * one takes the rejected `File`, and the kit's meaning is the one every caller already\n * writes. Everything else a `<div>` takes reaches the root — which carries\n * `role=\"group\"` named by `dropLabel`, so that is also where an `aria-label` or an\n * `aria-describedby` belongs.\n *\n * Two modes. SINGLE (the default, and all of 0.5): `file` + `onFileSelected`. MULTIPLE\n * (`multiple`): `files` + `onFilesSelected`. They are optional rather than a union so\n * `FileDropzoneProps` stays an interface a caller can extend; a single-mode pick also\n * reaches `onFilesSelected` as a one-element array, so one handler can serve both.\n */\nexport interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"onInvalid\"> {\n /** Single mode: the chosen file, echoed with its size. */\n file?: File | null;\n /** Single mode: called with the accepted file. */\n onFileSelected?: (file: File) => void;\n /** Take several files per pick or drop. Without it a drop of several keeps the first. */\n multiple?: boolean;\n /** Multiple mode: the chosen files, listed in the zone. */\n files?: readonly File[];\n /** Every accepted file of one pick, in order. Never called with an empty array. */\n onFilesSelected?: (files: File[]) => void;\n /**\n * Show a remove button inside the zone once something is chosen (kastlan asked for\n * it: the only way to un-choose a file was to reload). Single mode: removes the file.\n * Multiple mode: a \"remove all\" button. The host clears its own state here — the\n * zone is controlled. Focus moves to the Browse button, and the removal is announced.\n */\n onClear?: () => void;\n /** Multiple mode: a remove button per listed file. */\n onRemove?: (file: File, index: number) => void;\n accept: string;\n /**\n * The caller's check. Optional since 0.6: without it the zone checks `accept` itself\n * (a drop ignores the dialog's filter). With it, `accept` is NOT re-checked — every\n * 0.5 caller's `isValid` is its own type check, and doubling it could refuse a file\n * the caller accepts (a `.zip` the OS labelled `application/octet-stream`).\n */\n isValid?: (file: File) => boolean;\n /** The message for an `isValid` refusal. Defaults to `labels.rejectedInvalid`. */\n invalidMessage?: string;\n /** Bytes; larger files are refused. */\n maxSize?: number;\n /** Multiple mode: files one pick may add. Pass what is LEFT for a running cap. */\n maxFiles?: number;\n /**\n * Called once per refused file. The 0.5 hook, kept as it was; {@link onReject}\n * carries the reason and the translated message as well.\n *\n * Its existence used to be what switched the toast off, and it still does (see\n * {@link rejectionFeedback}). The toast is a dynamic `import(\"sonner\")` on the\n * failure path only: `sonner` is an OPTIONAL peer, and a static import here once\n * broke `import { Button }` for every app without it (see\n * `optional-peer-imports.test.tsx`).\n */\n onInvalid?: (file: File) => void;\n /** Every refused file of one pick, with its reason and message. */\n onReject?: (rejections: FileRejection[]) => void;\n /** The whole pick at once, before any of the above — return `false` to refuse all\n * of it (see {@link FilePickHandler}). A refused pick selects nothing; its one\n * message (`labels.rejectedPick`) goes where `rejectionFeedback` says. */\n onPick?: FilePickHandler;\n /** See {@link FileDropzoneRejectionFeedback}. Defaults to `\"toast\"` when neither\n * `onInvalid` nor `onReject` is given, `\"none\"` when one is. */\n rejectionFeedback?: FileDropzoneRejectionFeedback;\n /** Per-instance overrides of the `filePicker` label namespace. */\n labels?: Partial<FilePickerLabels>;\n /** The dropzone's accessible name. A caller's own `aria-label` wins over it — see\n * the root element below. Optional since 0.8, like the three below: each falls back\n * to its `filePicker` string from `<UiKitProvider labels>`, then to English. They\n * were required, so every call site passed four strings through `t()` by hand, and a\n * zone nested in a kit component (the feedback form) had no call site to pass them. */\n dropLabel?: string;\n /** Default `filePicker.browse`. */\n browseLabel?: string;\n /** What the zone says while nothing is chosen. Default `filePicker.empty` /\n * `emptyMultiple`. */\n emptyLabel?: string;\n /** The second line, e.g. what may be dropped. Default `filePicker.hint(accept)`. */\n hint?: string;\n /**\n * Not taking files: no drop, no picker, no remove buttons; `aria-disabled` on the\n * group. keksdose locks its invoice zone while another form holds unsaved changes,\n * and hand-rolled the whole zone to be able to (invoice-upload.tsx, \"Locked\").\n */\n disabled?: boolean;\n /**\n * Working on what it was given — an upload, an OCR pass. Inert like `disabled`, plus\n * `aria-busy`, a spinner in place of the upload icon, and `filePicker.busy` as the\n * text. The other half of why keksdose's invoice zone was hand-rolled.\n */\n busy?: boolean;\n /**\n * Replace the zone's body — the icon, the text and the chosen-file list — with your\n * own, given the zone's state: keksdose's spinner-plus-\"uploading\", its multi-shot\n * hint (\"several photos are possible\"), a second trigger for the camera.\n *\n * The Browse button, the inline error and the live regions stay: Browse is the\n * keyboard path, and a body that forgot it would leave the zone unreachable without\n * a pointer. A click on a control inside the body does not also open the picker.\n */\n renderBody?: (state: FileDropzoneState) => ReactNode;\n /** Extra classes for the dropzone's root. */\n className?: string;\n}\n\n/** Drag-and-drop file picker shared by the import wizards (YNAB zip, CAMT xml) and\n * kastlan's document forms. Refusals go to `onReject`/`onInvalid` and to\n * `rejectionFeedback`; the chosen file(s) are echoed with their size, otherwise\n * `emptyLabel` + `hint` describe what to drop. For a trigger with no drop area, see\n * `FileButton`. */\nexport function FileDropzone({\n file,\n onFileSelected,\n multiple,\n files,\n onFilesSelected,\n onClear,\n onRemove,\n accept,\n isValid,\n invalidMessage,\n maxSize,\n maxFiles,\n onInvalid,\n onReject,\n onPick,\n rejectionFeedback,\n labels: labelsProp,\n dropLabel: dropLabelProp,\n browseLabel: browseLabelProp,\n emptyLabel: emptyLabelProp,\n hint: hintProp,\n disabled,\n busy,\n renderBody,\n className,\n \"aria-label\": ariaLabel,\n \"aria-describedby\": describedByProp,\n ...rest\n}: FileDropzoneProps) {\n const [error, setError] = useState<string | null>(null);\n const inert = Boolean(disabled || busy);\n // State rather than a ref: `open` is handed to `renderBody` during render, and a\n // function that reads a ref there is exactly what the refs rule cannot prove safe.\n const [fileInput, setFileInput] = useState<HTMLInputElement | null>(null);\n const browseRef = useRef<HTMLButtonElement>(null);\n const errorId = useId();\n // The size through the kit's `file.size` label (the provider's locale by default).\n // This was `${n / 1024} KB`: ASCII digits, an English unit, and a binary kilobyte\n // mislabelled as a decimal one, all in one template literal.\n const fileText = useKitFileLabels();\n const labels = useKitLabels(\"filePicker\", DEFAULT_FILE_PICKER_LABELS, labelsProp);\n // Two regions, two urgencies: a pick or a removal is news, a refusal is a failure.\n // The echoed file name is only text in the zone, and text changing somewhere on the\n // page is not announced by itself.\n const status = useAnnounce();\n const alert = useAnnounce({ politeness: \"assertive\" });\n const feedback: FileDropzoneRejectionFeedback =\n rejectionFeedback ?? (onInvalid || onReject ? \"none\" : \"toast\");\n const dropLabel = dropLabelProp ?? labels.dropzone;\n const browseLabel = browseLabelProp ?? labels.browse;\n const emptyLabel = emptyLabelProp ?? (multiple ? labels.emptyMultiple : labels.empty);\n const hint = hintProp ?? labels.hint(formatAccept(accept));\n\n const acceptFiles = (list: ArrayLike<File> | null | undefined) => {\n if (inert || !list || list.length === 0) return;\n const all = Array.from(list);\n const picked = multiple ? all : all.slice(0, 1);\n const { accepted, rejected } = screenFiles(\n picked,\n {\n accept: isValid ? undefined : accept,\n maxSize,\n maxFiles: multiple ? maxFiles : undefined,\n isValid,\n invalidMessage,\n },\n labels,\n fileText.size,\n );\n const stands = judgePick(onPick, accepted, rejected);\n if (stands && accepted.length > 0) {\n setError(null);\n if (multiple) {\n onFilesSelected?.(accepted);\n } else {\n onFileSelected?.(accepted[0]);\n onFilesSelected?.(accepted);\n }\n status.announce(labels.selected(accepted.length, accepted[0].name));\n }\n if (!stands || rejected.length > 0) {\n for (const r of rejected) onInvalid?.(r.file);\n if (rejected.length > 0) onReject?.(rejected);\n const message = stands\n ? summariseRejections(rejected, labels)\n : labels.rejectedPick(picked.length);\n if (feedback === \"toast\") {\n // sonner is an optional peer: imported here, on the failure path only, so an\n // app that never trips this never has to install it. Not awaited — nothing\n // downstream depends on the toast having appeared, and the handlers that\n // reach this are DOM events. sonner's own toaster is a live region, so this\n // path does not announce a second time.\n void import(\"sonner\").then(({ toast }) => toast.error(message));\n } else {\n if (feedback === \"inline\") setError(message);\n alert.announce(message);\n }\n }\n };\n\n // The remove buttons unmount with the file they removed, and focus would fall to\n // <body>. Browse is where the next action (pick again) starts.\n const afterRemoval = (message: string) => {\n browseRef.current?.focus();\n status.announce(message);\n };\n\n // The enter/leave counter lives in the shared hook: the boolean this was flickered\n // across every child of the zone in Safari, whose drag events carry no\n // `relatedTarget` to tell a move onto a child from a real leave.\n const { dropProps, isOver: dragOver } = useDragTarget<HTMLDivElement>({\n disabled: inert,\n onDropFiles: acceptFiles,\n });\n const open = () => {\n if (!inert) fileInput?.click();\n };\n\n const chosen: readonly File[] = multiple ? (files ?? []) : file ? [file] : [];\n const showError = feedback === \"inline\" && error !== null;\n const describedBy = cn(describedByProp, showError && errorId) || undefined;\n\n return (\n <>\n {/* A named group, NOT a `role=\"button\"`. It was one, with real buttons (Browse,\n the remove buttons) inside it: nested interactive controls, which ARIA forbids\n because a button's children are presentational — a screen reader either read\n the zone as one opaque button or skipped the inner ones, and the zone was an\n extra Tab stop that did what Browse does. Now the keyboard path is Browse, a\n real button; the zone keeps its drop target and its click-anywhere for a\n pointer, which is a convenience on top of Browse rather than the only way in. */}\n {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions -- the click is a pointer shortcut for Browse, see above */}\n <div\n // `...rest` first: every handler below is the drop gesture itself.\n {...rest}\n {...dropProps}\n // Pointer only; every control inside stops its own click from reaching here\n // (and one in a custom body is skipped below). The keyboard equivalent is the\n // Browse button, so no key handler belongs on a group.\n onClick={(e) => {\n if (fromControl(e)) return;\n open();\n }}\n role=\"group\"\n // The DOM spelling wins over `dropLabel`, which stays the name for every caller\n // that passes no `aria-label` — i.e. all of them today. Standardising on\n // `aria-label` (audit §api-design) does not get to silently rename an existing\n // required prop.\n aria-label={ariaLabel ?? dropLabel}\n // No `aria-invalid`: ARIA does not allow it on `role=\"group\"`. The message is\n // reached through the description instead, and spoken when it appears.\n aria-describedby={describedBy}\n data-invalid={showError || undefined}\n aria-disabled={disabled || undefined}\n aria-busy={busy || undefined}\n data-drag-over={dragOver || undefined}\n className={cn(\n // `relative` is load-bearing, not cosmetic. The file input below is `sr-only`,\n // which Tailwind implements as `position: absolute` — so without a positioned\n // ancestor its containing block is the INITIAL one, and it is laid out at its\n // own offset from the top of the document. Nothing looks wrong, because the\n // input is 1x1 and clipped; what breaks is the page height. On a long page\n // every escaped sr-only element extends `documentElement.scrollHeight` to its\n // own offset, which produces a second, whole-document scrollbar alongside the\n // app shell's own — one that scrolls past the end of the content into nothing.\n // Measured on the showcase: body 900px, document 47,919px.\n \"relative flex flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed px-4 py-6 text-center transition-colors\",\n // Three states out of two border tokens: the target rests on the plain\n // hairline, hover pulls it to `--border-strong`, and a live drag keeps that\n // border and adds the `--bg-active` wash on top — so \"let go here\" still reads\n // one step louder than \"you are over it\". A refusal shown inline takes the\n // danger border until the next good pick.\n // Inert zones keep the resting hairline and lose the hover pull: a border that\n // answers the pointer promises a drop the zone will refuse.\n disabled\n ? \"cursor-not-allowed border-[var(--border)] opacity-60\"\n : busy\n ? \"cursor-progress border-[var(--border)]\"\n : dragOver\n ? \"cursor-pointer border-[var(--border-strong)] bg-[var(--bg-active)]\"\n : showError\n ? \"cursor-pointer border-[var(--danger-border)]\"\n : \"cursor-pointer border-[var(--border)] hover:border-[var(--border-strong)]\",\n className,\n )}\n >\n {renderBody ? (\n renderBody({\n dragOver,\n disabled: Boolean(disabled),\n busy: Boolean(busy),\n files: chosen,\n error: showError ? error : null,\n open,\n labels,\n })\n ) : busy ? (\n <>\n {/* `label={null}`: the zone is `aria-busy` and the line below says the\n same in words; a spoken \"Loading\" would only run into it. */}\n <Spinner label={null} className=\"size-5\" />\n <div className=\"text-sm text-[var(--text-secondary)]\">{labels.busy}</div>\n </>\n ) : (\n <>\n <Upload aria-hidden className=\"size-5 text-[var(--text-muted)]\" />\n {multiple && chosen.length > 0 ? (\n <ul className=\"flex w-full max-w-sm flex-col gap-1 text-start\">\n {chosen.map((f, i) => (\n <li\n // Name + size + position: two files may share a name, and the index\n // alone would re-key every row below a removal.\n key={`${f.name}-${f.size}-${i}`}\n className=\"flex items-center gap-2 text-sm text-[var(--text-secondary)]\"\n >\n <span className=\"min-w-0 flex-1 truncate font-medium\">{f.name}</span>\n <span className=\"shrink-0 text-xs text-[var(--text-muted)]\">{fileText.size(f.size)}</span>\n {onRemove && (\n <IconButton\n type=\"button\"\n size=\"sm\"\n disabled={inert}\n aria-label={labels.remove(f.name)}\n onClick={(e) => {\n e.stopPropagation();\n onRemove(f, i);\n afterRemoval(labels.removed(f.name));\n }}\n >\n <X aria-hidden />\n </IconButton>\n )}\n </li>\n ))}\n </ul>\n ) : (\n <>\n <div className=\"flex items-center gap-1 text-sm text-[var(--text-secondary)]\">\n {/* `!multiple`: in multiple mode an empty `files` list is \"nothing chosen\",\n whatever a stray `file` prop says — it used to show that file's name. */}\n {!multiple && file ? <span className=\"font-medium\">{file.name}</span> : emptyLabel}\n {!multiple && file && onClear && (\n <IconButton\n type=\"button\"\n size=\"sm\"\n disabled={inert}\n aria-label={labels.remove(file.name)}\n onClick={(e) => {\n e.stopPropagation();\n onClear();\n afterRemoval(labels.removed(file.name));\n }}\n >\n <X aria-hidden />\n </IconButton>\n )}\n </div>\n <div className=\"text-xs text-[var(--text-muted)]\">\n {file && !multiple ? fileText.size(file.size) : hint}\n </div>\n </>\n )}\n </>\n )}\n <div className=\"flex flex-wrap items-center justify-center gap-2\">\n <Button\n ref={browseRef}\n type=\"button\"\n variant=\"secondary\"\n disabled={inert}\n onClick={(e) => {\n e.stopPropagation();\n open();\n }}\n >\n {browseLabel}\n </Button>\n {multiple && chosen.length > 0 && onClear && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n disabled={inert}\n onClick={(e) => {\n e.stopPropagation();\n onClear();\n afterRemoval(labels.cleared);\n }}\n >\n {labels.clearAll}\n </Button>\n )}\n </div>\n {showError && (\n <p id={errorId} className=\"text-xs text-[var(--danger)]\">\n {error}\n </p>\n )}\n <input\n ref={setFileInput}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n disabled={inert}\n onChange={(e) => {\n // Copy, THEN reset: `files` is a live view of the value, and without the\n // reset picking the same file a second time fires no `change` at all —\n // the retry after a failed upload silently did nothing (kastlan).\n const picked = Array.from(e.currentTarget.files ?? []);\n e.currentTarget.value = \"\";\n acceptFiles(picked);\n }}\n // Clicks are forwarded here programmatically; one reaching the zone's own\n // handler again would open the dialog a second time.\n onClick={(e) => e.stopPropagation()}\n className=\"sr-only\"\n />\n </div>\n {/* Outside the zone, where they have always been (the zone used to be a\n `role=\"button\"`, whose content is presentational). `sr-only-fixed`, so they\n need no positioned ancestor. */}\n <span {...status.regionProps} />\n <span {...alert.regionProps} />\n </>\n );\n}\n\n/** A click that landed on a control inside the zone (a custom body's own button, a\n * link in the hint) is that control's, not a request to open the picker. */\nfunction fromControl(e: MouseEvent<HTMLElement>): boolean {\n const target = e.target as Element | null;\n const control = target?.closest?.(\"button, a[href], input, select, textarea, label, [role='button']\");\n return Boolean(control && control !== e.currentTarget && e.currentTarget.contains(control));\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ButtonHTMLAttributes, ReactNode, Ref } from 'react';
|
|
3
|
+
|
|
4
|
+
interface FloatingPanelLabels {
|
|
5
|
+
/** Accessible name of the panel header's X. */
|
|
6
|
+
close: string;
|
|
7
|
+
}
|
|
8
|
+
declare const DEFAULT_FLOATING_PANEL_LABELS: FloatingPanelLabels;
|
|
9
|
+
/** A LOGICAL corner: `bottom-end` is bottom-right in LTR and bottom-left in RTL. */
|
|
10
|
+
type FloatingCorner = "bottom-end" | "bottom-start";
|
|
11
|
+
interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
|
12
|
+
/** The accessible name — the button shows only its icon. */
|
|
13
|
+
label: string;
|
|
14
|
+
icon: ReactNode;
|
|
15
|
+
/** Default `bottom-end`. */
|
|
16
|
+
corner?: FloatingCorner;
|
|
17
|
+
/**
|
|
18
|
+
* Distance above the nav / home indicator, as a CSS length. Default `1rem`. Raise it
|
|
19
|
+
* to stack above another floating control in the same corner — keksdose's assistant
|
|
20
|
+
* clears the transactions page's own action group this way (`calc(1rem + 4rem)`).
|
|
21
|
+
*/
|
|
22
|
+
offset?: string;
|
|
23
|
+
ref?: Ref<HTMLButtonElement>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A round, icon-only button pinned to a corner of the viewport — the trigger of a
|
|
27
|
+
* {@link FloatingPanel}, and usable alone for a page's one primary action.
|
|
28
|
+
*
|
|
29
|
+
* keksdose hand-placed each of its floating controls: the assistant launcher at
|
|
30
|
+
* `fixed bottom-36 right-4 md:bottom-6`, the feedback page's and the transactions
|
|
31
|
+
* page's action groups at `right-4 bottom-20`. Every one of those numbers is a guess
|
|
32
|
+
* at the phone nav's height plus whatever else floats there (Keksdose live #314 found
|
|
33
|
+
* out what such a guess is worth for a footer), `right` rather than `end` puts it over
|
|
34
|
+
* the page's leading edge in RTL, and none allows for the home indicator. Here the
|
|
35
|
+
* base is `--app-nav-h` and the safe-area inset, the corner is logical, and
|
|
36
|
+
* {@link FloatingActionButtonProps.offset} is the one number left to the caller — the
|
|
37
|
+
* height of whatever else it has to clear, not the nav's.
|
|
38
|
+
*
|
|
39
|
+
* Portalled, because `position: fixed` inside any transformed ancestor (an animated
|
|
40
|
+
* page transition) is fixed to that ancestor, not to the screen.
|
|
41
|
+
*/
|
|
42
|
+
declare function FloatingActionButton({ label, icon, corner, offset, className, style, type, ...rest }: FloatingActionButtonProps): react.JSX.Element;
|
|
43
|
+
interface FloatingPanelProps {
|
|
44
|
+
/** The panel's heading, and its accessible name. */
|
|
45
|
+
title: ReactNode;
|
|
46
|
+
/** The trigger's accessible name ("Assistant", "Send feedback"). */
|
|
47
|
+
fabLabel: string;
|
|
48
|
+
fabIcon: ReactNode;
|
|
49
|
+
/** Controlled open state. Leave it out to let the panel own it. */
|
|
50
|
+
open?: boolean;
|
|
51
|
+
/** Uncontrolled initial state. Default closed. */
|
|
52
|
+
defaultOpen?: boolean;
|
|
53
|
+
/** Every request to open or close — the FAB, the X, Escape. */
|
|
54
|
+
onOpenChange?: (open: boolean) => void;
|
|
55
|
+
/** Default `bottom-end`. */
|
|
56
|
+
corner?: FloatingCorner;
|
|
57
|
+
/** The FAB's distance above the nav — see {@link FloatingActionButtonProps.offset}.
|
|
58
|
+
* From `md` up the card sits above the FAB, so it moves with it. */
|
|
59
|
+
offset?: string;
|
|
60
|
+
/** Default: `floatingPanel.close` from the {@link UiKitProvider}, else "Close". */
|
|
61
|
+
closeLabel?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Where focus goes on open. Default the panel itself, so a phone's keyboard does not
|
|
64
|
+
* rise over the sheet before the user has asked to type; pass the chat field for a
|
|
65
|
+
* panel whose one job is typing.
|
|
66
|
+
*/
|
|
67
|
+
initialFocus?: () => HTMLElement | null;
|
|
68
|
+
/** Extra classes for the panel. */
|
|
69
|
+
className?: string;
|
|
70
|
+
/** Extra classes for the scrolling body. */
|
|
71
|
+
bodyClassName?: string;
|
|
72
|
+
children: ReactNode;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A docked, NON-MODAL panel in a corner of the screen, opened by a
|
|
76
|
+
* {@link FloatingActionButton} — keksdose's assistant launcher and its feedback page,
|
|
77
|
+
* which each built one.
|
|
78
|
+
*
|
|
79
|
+
* ## Not a Modal
|
|
80
|
+
*
|
|
81
|
+
* The page stays usable while it is open: that is the point of an assistant beside the
|
|
82
|
+
* data it is asked about. So there is no backdrop, no scroll lock, no `aria-modal` and
|
|
83
|
+
* no focus trap — Tab walks out of the panel into the page and back, and a click on the
|
|
84
|
+
* page does not close it. It is `role="dialog"` without `aria-modal`, which is what
|
|
85
|
+
* ARIA calls a non-modal dialog, named by its heading.
|
|
86
|
+
*
|
|
87
|
+
* What it keeps from a dialog is the focus HANDOFF: opening moves focus into it (a
|
|
88
|
+
* keyboard user who pressed the FAB should not have to hunt for what appeared), and
|
|
89
|
+
* closing gives it back to the FAB — but only when focus was in the panel, so a panel
|
|
90
|
+
* closed from the page (a controlled owner) never pulls focus out of the field the
|
|
91
|
+
* user is typing in. Escape closes it while focus is inside, and only then: on the
|
|
92
|
+
* panel element, not the document, so Escape in the page keeps meaning whatever the
|
|
93
|
+
* page means by it.
|
|
94
|
+
*
|
|
95
|
+
* ## Layout
|
|
96
|
+
*
|
|
97
|
+
* From `md` up, a card above the FAB in its corner. Below `md`, a full-width sheet
|
|
98
|
+
* docked to the bottom, sitting ON the `AppShell` bottom nav (`--app-nav-h`) and
|
|
99
|
+
* clear of the home indicator. Pure CSS — no width is measured in JavaScript.
|
|
100
|
+
*
|
|
101
|
+
* The FAB carries `aria-expanded` and, while the panel exists, `aria-controls`. The
|
|
102
|
+
* panel unmounts when closed; an app that must keep a draft across a close keeps it in
|
|
103
|
+
* its own state, where it also survives a reload.
|
|
104
|
+
*/
|
|
105
|
+
declare function FloatingPanel({ title, fabLabel, fabIcon, open: openProp, defaultOpen, onOpenChange, corner, offset, closeLabel, initialFocus, className, bodyClassName, children, }: FloatingPanelProps): react.JSX.Element;
|
|
106
|
+
|
|
107
|
+
export { DEFAULT_FLOATING_PANEL_LABELS, FloatingActionButton, type FloatingActionButtonProps, type FloatingCorner, FloatingPanel, type FloatingPanelLabels, type FloatingPanelProps };
|