@eifi1/ui-kit 0.6.1 → 0.7.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 +69 -33
- package/dist/chart.d.ts +1 -0
- package/dist/components/account-settings.js +3 -2
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/amount-input.d.ts +3 -0
- package/dist/components/amount-input.js +6 -4
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.d.ts +33 -6
- package/dist/components/autocomplete.js +62 -16
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/calculator.d.ts +3 -0
- package/dist/components/calculator.js +2 -1
- package/dist/components/calculator.js.map +1 -1
- package/dist/components/chart-zoom.js +2 -1
- package/dist/components/chart-zoom.js.map +1 -1
- package/dist/components/chart.d.ts +33 -2
- package/dist/components/chart.js +20 -8
- package/dist/components/chart.js.map +1 -1
- package/dist/components/chip.d.ts +34 -14
- package/dist/components/chip.js +63 -28
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.d.ts +18 -1
- package/dist/components/choice-card.js +21 -8
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/combobox-core.d.ts +61 -2
- package/dist/components/combobox-core.js +68 -17
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +11 -7
- package/dist/components/combobox.js +51 -28
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/currency-select.js +3 -3
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +30 -6
- package/dist/components/danger-confirm.js +51 -32
- package/dist/components/danger-confirm.js.map +1 -1
- 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 +6 -3
- package/dist/components/data-table-pagination.js +6 -5
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +332 -248
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.js +17 -7
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +28 -3
- package/dist/components/dialog-frame.js +47 -11
- package/dist/components/dialog-frame.js.map +1 -1
- package/dist/components/disclosure.d.ts +22 -2
- package/dist/components/disclosure.js +41 -8
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/dropdown.d.ts +7 -3
- package/dist/components/dropdown.js +6 -2
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +17 -8
- package/dist/components/entity-combobox.js +7 -5
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/file-button.d.ts +36 -5
- package/dist/components/file-button.js +23 -2
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +9 -5
- package/dist/components/file-dropzone.js +14 -19
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/grouped-picker.js +2 -2
- package/dist/components/grouped-picker.js.map +1 -1
- package/dist/components/hover-menu.d.ts +8 -3
- package/dist/components/hover-menu.js +88 -4
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/icon-picker.d.ts +10 -1
- package/dist/components/icon-picker.js +5 -1
- package/dist/components/icon-picker.js.map +1 -1
- package/dist/components/measured-grid.d.ts +165 -0
- package/dist/components/measured-grid.js +559 -0
- package/dist/components/measured-grid.js.map +1 -0
- package/dist/components/mini-calendar.js +7 -8
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/month-picker.js +13 -9
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.js +2 -2
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.d.ts +9 -1
- package/dist/components/multi-select.js +22 -8
- package/dist/components/multi-select.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/number-input.js +3 -3
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +3 -0
- package/dist/components/numpad-sheet.js +3 -3
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/picker-sheet.js +1 -1
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/popover.js +11 -2
- package/dist/components/popover.js.map +1 -1
- package/dist/components/series-chart-ticks.d.ts +13 -0
- package/dist/components/series-chart-ticks.js +29 -0
- package/dist/components/series-chart-ticks.js.map +1 -0
- package/dist/components/series-chart.js +33 -27
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/swatch-picker.d.ts +10 -1
- package/dist/components/swatch-picker.js +2 -0
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/swipeable-row.d.ts +11 -2
- package/dist/components/swipeable-row.js +5 -3
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/tile-radio.js +5 -7
- package/dist/components/tile-radio.js.map +1 -1
- package/dist/components/toggle-group.d.ts +13 -3
- package/dist/components/toggle-group.js +21 -1
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.js +1 -2
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/treemap.d.ts +6 -1
- package/dist/components/treemap.js +16 -6
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/ui.d.ts +31 -7
- package/dist/components/ui.js +26 -18
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-anchor-dir.d.ts +15 -0
- package/dist/components/use-anchor-dir.js +14 -0
- package/dist/components/use-anchor-dir.js.map +1 -0
- package/dist/components/use-mobile-reveal.d.ts +8 -1
- package/dist/components/use-mobile-reveal.js +3 -2
- package/dist/components/use-mobile-reveal.js.map +1 -1
- package/dist/components/use-table-state.d.ts +4 -4
- package/dist/components/use-table-state.js +12 -5
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/{data-table-filters-DM1auUfH.d.ts → data-table-filters-noy0Abvi.d.ts} +40 -7
- package/dist/data-table.d.ts +1 -1
- package/dist/data-table.js.map +1 -1
- package/dist/feedback/feedback-attachment.d.ts +22 -3
- package/dist/feedback/feedback-attachment.js +15 -7
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +3 -2
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback.d.ts +1 -1
- package/dist/hooks/use-windowed-rows.d.ts +29 -0
- package/dist/hooks/use-windowed-rows.js +31 -0
- package/dist/hooks/use-windowed-rows.js.map +1 -0
- package/dist/i18n/defaults.d.ts +3 -0
- package/dist/i18n/defaults.js +5 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +5 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH.d.ts +43 -0
- package/dist/i18n/locales/de-CH.js +21 -0
- package/dist/i18n/locales/de-CH.js.map +1 -0
- package/dist/i18n/locales/de.d.ts +43 -0
- package/dist/i18n/locales/de.js +344 -0
- package/dist/i18n/locales/de.js.map +1 -0
- package/dist/i18n/locales/es.d.ts +43 -0
- package/dist/i18n/locales/es.js +339 -0
- package/dist/i18n/locales/es.js.map +1 -0
- package/dist/i18n/locales/fr.d.ts +43 -0
- package/dist/i18n/locales/fr.js +344 -0
- package/dist/i18n/locales/fr.js.map +1 -0
- package/dist/i18n/locales/hu.d.ts +43 -0
- package/dist/i18n/locales/hu.js +343 -0
- package/dist/i18n/locales/hu.js.map +1 -0
- package/dist/i18n/locales/it.d.ts +43 -0
- package/dist/i18n/locales/it.js +342 -0
- package/dist/i18n/locales/it.js.map +1 -0
- package/dist/i18n/locales/zh.d.ts +43 -0
- package/dist/i18n/locales/zh.js +338 -0
- package/dist/i18n/locales/zh.js.map +1 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/direction.d.ts +24 -0
- package/dist/lib/direction.js +19 -0
- package/dist/lib/direction.js.map +1 -0
- package/dist/rhf/form.js.map +1 -1
- package/dist/search/command-palette.d.ts +5 -0
- package/dist/search/command-palette.js +80 -50
- package/dist/search/command-palette.js.map +1 -1
- package/dist/shell/app-shell.d.ts +13 -1
- package/dist/shell/app-shell.js +133 -84
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/topbar-action-menu.js +1 -1
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.d.ts +7 -3
- package/dist/shell/topbar-controls.js +38 -31
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/theme/chart-palette.d.ts +7 -1
- package/dist/theme/chart-palette.js +2 -1
- package/dist/theme/chart-palette.js.map +1 -1
- package/dist/tour/tour.d.ts +11 -5
- package/dist/tour/tour.js +124 -58
- package/dist/tour/tour.js.map +1 -1
- package/dist/wizard/use-wizard.js +30 -13
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/wizard-summary.js +1 -1
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/package.json +12 -2
- package/src/components/account-settings.tsx +6 -2
- package/src/components/amount-input.tsx +10 -8
- package/src/components/autocomplete.tsx +110 -24
- package/src/components/calculator.tsx +5 -1
- package/src/components/chart-zoom.tsx +8 -1
- package/src/components/chart.tsx +68 -7
- package/src/components/chip.tsx +131 -49
- package/src/components/choice-card.tsx +77 -36
- package/src/components/combobox-core.tsx +147 -20
- package/src/components/combobox.tsx +100 -44
- package/src/components/currency-select.tsx +6 -6
- package/src/components/danger-confirm.tsx +90 -36
- package/src/components/data-table-pagination.tsx +32 -19
- package/src/components/data-table.tsx +198 -36
- package/src/components/date-picker.tsx +24 -7
- package/src/components/dialog-frame.tsx +97 -22
- package/src/components/disclosure.tsx +75 -13
- package/src/components/dropdown.tsx +17 -5
- package/src/components/entity-combobox.tsx +26 -13
- package/src/components/file-button.tsx +74 -5
- package/src/components/file-dropzone.tsx +44 -35
- package/src/components/grouped-picker.tsx +2 -2
- package/src/components/hover-menu.tsx +139 -10
- package/src/components/icon-picker.tsx +14 -1
- package/src/components/measured-grid.tsx +852 -0
- package/src/components/mini-calendar.tsx +11 -8
- package/src/components/month-picker.tsx +18 -11
- package/src/components/multi-entity-combobox.tsx +2 -2
- package/src/components/multi-select.tsx +34 -7
- package/src/components/number-input.tsx +4 -4
- package/src/components/numpad-sheet.tsx +5 -3
- package/src/components/picker-sheet.tsx +1 -1
- package/src/components/popover.tsx +18 -3
- package/src/components/series-chart-ticks.ts +52 -0
- package/src/components/series-chart.tsx +39 -31
- package/src/components/swatch-picker.tsx +11 -0
- package/src/components/swipeable-row.tsx +21 -6
- package/src/components/tile-radio.tsx +5 -7
- package/src/components/toggle-group.tsx +39 -3
- package/src/components/toggle-legend.tsx +5 -2
- package/src/components/treemap.tsx +28 -5
- package/src/components/ui.tsx +74 -30
- package/src/components/use-anchor-dir.ts +20 -0
- package/src/components/use-mobile-reveal.ts +10 -2
- package/src/components/use-table-state.ts +44 -7
- package/src/data-table.ts +1 -0
- package/src/feedback/feedback-attachment.tsx +36 -9
- package/src/feedback/feedback-dialog.tsx +3 -2
- package/src/hooks/use-windowed-rows.ts +69 -0
- package/src/i18n/defaults.ts +4 -0
- package/src/i18n/kit-labels.tsx +4 -0
- package/src/i18n/locales/de-CH.ts +30 -0
- package/src/i18n/locales/de.ts +365 -0
- package/src/i18n/locales/es.ts +358 -0
- package/src/i18n/locales/fr.ts +371 -0
- package/src/i18n/locales/hu.ts +359 -0
- package/src/i18n/locales/it.ts +360 -0
- package/src/i18n/locales/zh.ts +348 -0
- package/src/index.ts +10 -1
- package/src/lib/direction.ts +37 -0
- package/src/rhf/form.tsx +21 -0
- package/src/search/command-palette.tsx +77 -19
- package/src/shell/app-shell.tsx +200 -129
- package/src/shell/topbar-action-menu.tsx +1 -1
- package/src/shell/topbar-controls.tsx +19 -8
- package/src/theme/chart-palette.ts +9 -2
- package/src/tour/tour.tsx +119 -18
- package/src/wizard/use-wizard.ts +55 -17
- package/src/wizard/wizard-summary.tsx +1 -1
|
@@ -5,8 +5,8 @@ import { Button, IconButton } 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 { DEFAULT_FILE_PICKER_LABELS, screenFiles, summariseRejections } from "./file-button";
|
|
9
|
-
import type { FilePickerLabels, FileRejection } from "./file-button";
|
|
8
|
+
import { DEFAULT_FILE_PICKER_LABELS, judgePick, screenFiles, summariseRejections } from "./file-button";
|
|
9
|
+
import type { FilePickHandler, FilePickerLabels, FileRejection } from "./file-button";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Where a refused file's message goes.
|
|
@@ -26,8 +26,8 @@ export type FileDropzoneRejectionFeedback = "toast" | "inline" | "none";
|
|
|
26
26
|
* The DOM event of that name belongs to form validation and takes a `FormEvent`; this
|
|
27
27
|
* one takes the rejected `File`, and the kit's meaning is the one every caller already
|
|
28
28
|
* writes. Everything else a `<div>` takes reaches the root — which carries
|
|
29
|
-
* `role="
|
|
30
|
-
* belongs.
|
|
29
|
+
* `role="group"` named by `dropLabel`, so that is also where an `aria-label` or an
|
|
30
|
+
* `aria-describedby` belongs.
|
|
31
31
|
*
|
|
32
32
|
* Two modes. SINGLE (the default, and all of 0.5): `file` + `onFileSelected`. MULTIPLE
|
|
33
33
|
* (`multiple`): `files` + `onFilesSelected`. They are optional rather than a union so
|
|
@@ -49,7 +49,7 @@ export interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">,
|
|
|
49
49
|
* Show a remove button inside the zone once something is chosen (kastlan asked for
|
|
50
50
|
* it: the only way to un-choose a file was to reload). Single mode: removes the file.
|
|
51
51
|
* Multiple mode: a "remove all" button. The host clears its own state here — the
|
|
52
|
-
* zone is controlled. Focus
|
|
52
|
+
* zone is controlled. Focus moves to the Browse button, and the removal is announced.
|
|
53
53
|
*/
|
|
54
54
|
onClear?: () => void;
|
|
55
55
|
/** Multiple mode: a remove button per listed file. */
|
|
@@ -81,6 +81,10 @@ export interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">,
|
|
|
81
81
|
onInvalid?: (file: File) => void;
|
|
82
82
|
/** Every refused file of one pick, with its reason and message. */
|
|
83
83
|
onReject?: (rejections: FileRejection[]) => void;
|
|
84
|
+
/** The whole pick at once, before any of the above — return `false` to refuse all
|
|
85
|
+
* of it (see {@link FilePickHandler}). A refused pick selects nothing; its one
|
|
86
|
+
* message (`labels.rejectedPick`) goes where `rejectionFeedback` says. */
|
|
87
|
+
onPick?: FilePickHandler;
|
|
84
88
|
/** See {@link FileDropzoneRejectionFeedback}. Defaults to `"toast"` when neither
|
|
85
89
|
* `onInvalid` nor `onReject` is given, `"none"` when one is. */
|
|
86
90
|
rejectionFeedback?: FileDropzoneRejectionFeedback;
|
|
@@ -116,6 +120,7 @@ export function FileDropzone({
|
|
|
116
120
|
maxFiles,
|
|
117
121
|
onInvalid,
|
|
118
122
|
onReject,
|
|
123
|
+
onPick,
|
|
119
124
|
rejectionFeedback,
|
|
120
125
|
labels: labelsProp,
|
|
121
126
|
dropLabel,
|
|
@@ -130,7 +135,7 @@ export function FileDropzone({
|
|
|
130
135
|
const [dragOver, setDragOver] = useState(false);
|
|
131
136
|
const [error, setError] = useState<string | null>(null);
|
|
132
137
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
133
|
-
const
|
|
138
|
+
const browseRef = useRef<HTMLButtonElement>(null);
|
|
134
139
|
const errorId = useId();
|
|
135
140
|
// The size through the kit's `file.size` label (the provider's locale by default).
|
|
136
141
|
// This was `${n / 1024} KB`: ASCII digits, an English unit, and a binary kilobyte
|
|
@@ -138,9 +143,8 @@ export function FileDropzone({
|
|
|
138
143
|
const fileText = useKitFileLabels();
|
|
139
144
|
const labels = useKitLabels("filePicker", DEFAULT_FILE_PICKER_LABELS, labelsProp);
|
|
140
145
|
// Two regions, two urgencies: a pick or a removal is news, a refusal is a failure.
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
// reader on its own.
|
|
146
|
+
// The echoed file name is only text in the zone, and text changing somewhere on the
|
|
147
|
+
// page is not announced by itself.
|
|
144
148
|
const status = useAnnounce();
|
|
145
149
|
const alert = useAnnounce({ politeness: "assertive" });
|
|
146
150
|
const feedback: FileDropzoneRejectionFeedback =
|
|
@@ -149,8 +153,9 @@ export function FileDropzone({
|
|
|
149
153
|
const acceptFiles = (list: ArrayLike<File> | null | undefined) => {
|
|
150
154
|
if (!list || list.length === 0) return;
|
|
151
155
|
const all = Array.from(list);
|
|
156
|
+
const picked = multiple ? all : all.slice(0, 1);
|
|
152
157
|
const { accepted, rejected } = screenFiles(
|
|
153
|
-
|
|
158
|
+
picked,
|
|
154
159
|
{
|
|
155
160
|
accept: isValid ? undefined : accept,
|
|
156
161
|
maxSize,
|
|
@@ -161,7 +166,8 @@ export function FileDropzone({
|
|
|
161
166
|
labels,
|
|
162
167
|
fileText.size,
|
|
163
168
|
);
|
|
164
|
-
|
|
169
|
+
const stands = judgePick(onPick, accepted, rejected);
|
|
170
|
+
if (stands && accepted.length > 0) {
|
|
165
171
|
setError(null);
|
|
166
172
|
if (multiple) {
|
|
167
173
|
onFilesSelected?.(accepted);
|
|
@@ -171,10 +177,12 @@ export function FileDropzone({
|
|
|
171
177
|
}
|
|
172
178
|
status.announce(labels.selected(accepted.length, accepted[0].name));
|
|
173
179
|
}
|
|
174
|
-
if (rejected.length > 0) {
|
|
180
|
+
if (!stands || rejected.length > 0) {
|
|
175
181
|
for (const r of rejected) onInvalid?.(r.file);
|
|
176
|
-
onReject?.(rejected);
|
|
177
|
-
const message =
|
|
182
|
+
if (rejected.length > 0) onReject?.(rejected);
|
|
183
|
+
const message = stands
|
|
184
|
+
? summariseRejections(rejected, labels)
|
|
185
|
+
: labels.rejectedPick(picked.length);
|
|
178
186
|
if (feedback === "toast") {
|
|
179
187
|
// sonner is an optional peer: imported here, on the failure path only, so an
|
|
180
188
|
// app that never trips this never has to install it. Not awaited — nothing
|
|
@@ -190,9 +198,9 @@ export function FileDropzone({
|
|
|
190
198
|
};
|
|
191
199
|
|
|
192
200
|
// The remove buttons unmount with the file they removed, and focus would fall to
|
|
193
|
-
// <body>.
|
|
201
|
+
// <body>. Browse is where the next action (pick again) starts.
|
|
194
202
|
const afterRemoval = (message: string) => {
|
|
195
|
-
|
|
203
|
+
browseRef.current?.focus();
|
|
196
204
|
status.announce(message);
|
|
197
205
|
};
|
|
198
206
|
|
|
@@ -202,11 +210,17 @@ export function FileDropzone({
|
|
|
202
210
|
|
|
203
211
|
return (
|
|
204
212
|
<>
|
|
213
|
+
{/* A named group, NOT a `role="button"`. It was one, with real buttons (Browse,
|
|
214
|
+
the remove buttons) inside it: nested interactive controls, which ARIA forbids
|
|
215
|
+
because a button's children are presentational — a screen reader either read
|
|
216
|
+
the zone as one opaque button or skipped the inner ones, and the zone was an
|
|
217
|
+
extra Tab stop that did what Browse does. Now the keyboard path is Browse, a
|
|
218
|
+
real button; the zone keeps its drop target and its click-anywhere for a
|
|
219
|
+
pointer, which is a convenience on top of Browse rather than the only way in. */}
|
|
220
|
+
{/* 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 */}
|
|
205
221
|
<div
|
|
206
|
-
// `...rest` first: every handler below is the drop gesture itself
|
|
207
|
-
// `role`/`tabIndex` pair is what makes this div operable by keyboard at all.
|
|
222
|
+
// `...rest` first: every handler below is the drop gesture itself.
|
|
208
223
|
{...rest}
|
|
209
|
-
ref={rootRef}
|
|
210
224
|
onDragEnter={(e) => {
|
|
211
225
|
e.preventDefault();
|
|
212
226
|
e.stopPropagation();
|
|
@@ -230,24 +244,16 @@ export function FileDropzone({
|
|
|
230
244
|
setDragOver(false);
|
|
231
245
|
acceptFiles(e.dataTransfer.files);
|
|
232
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.
|
|
233
249
|
onClick={() => fileInputRef.current?.click()}
|
|
234
|
-
|
|
235
|
-
// Only the zone's OWN keys. A remove button inside it is a real button, and
|
|
236
|
-
// Enter on it bubbling up here would open the picker instead of removing.
|
|
237
|
-
if (e.target !== e.currentTarget) return;
|
|
238
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
239
|
-
e.preventDefault();
|
|
240
|
-
fileInputRef.current?.click();
|
|
241
|
-
}
|
|
242
|
-
}}
|
|
243
|
-
role="button"
|
|
244
|
-
tabIndex={0}
|
|
250
|
+
role="group"
|
|
245
251
|
// The DOM spelling wins over `dropLabel`, which stays the name for every caller
|
|
246
252
|
// that passes no `aria-label` — i.e. all of them today. Standardising on
|
|
247
253
|
// `aria-label` (audit §api-design) does not get to silently rename an existing
|
|
248
254
|
// required prop.
|
|
249
255
|
aria-label={ariaLabel ?? dropLabel}
|
|
250
|
-
// No `aria-invalid`: ARIA does not allow it on `role="
|
|
256
|
+
// No `aria-invalid`: ARIA does not allow it on `role="group"`. The message is
|
|
251
257
|
// reached through the description instead, and spoken when it appears.
|
|
252
258
|
aria-describedby={describedBy}
|
|
253
259
|
data-invalid={showError || undefined}
|
|
@@ -307,7 +313,9 @@ export function FileDropzone({
|
|
|
307
313
|
) : (
|
|
308
314
|
<>
|
|
309
315
|
<div className="flex items-center gap-1 text-sm text-[var(--text-secondary)]">
|
|
310
|
-
{
|
|
316
|
+
{/* `!multiple`: in multiple mode an empty `files` list is "nothing chosen",
|
|
317
|
+
whatever a stray `file` prop says — it used to show that file's name. */}
|
|
318
|
+
{!multiple && file ? <span className="font-medium">{file.name}</span> : emptyLabel}
|
|
311
319
|
{!multiple && file && onClear && (
|
|
312
320
|
<IconButton
|
|
313
321
|
type="button"
|
|
@@ -330,6 +338,7 @@ export function FileDropzone({
|
|
|
330
338
|
)}
|
|
331
339
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
|
332
340
|
<Button
|
|
341
|
+
ref={browseRef}
|
|
333
342
|
type="button"
|
|
334
343
|
variant="secondary"
|
|
335
344
|
onClick={(e) => {
|
|
@@ -377,9 +386,9 @@ export function FileDropzone({
|
|
|
377
386
|
className="sr-only"
|
|
378
387
|
/>
|
|
379
388
|
</div>
|
|
380
|
-
{/* Outside the zone
|
|
381
|
-
|
|
382
|
-
|
|
389
|
+
{/* Outside the zone, where they have always been (the zone used to be a
|
|
390
|
+
`role="button"`, whose content is presentational). `sr-only-fixed`, so they
|
|
391
|
+
need no positioned ancestor. */}
|
|
383
392
|
<span {...status.regionProps} />
|
|
384
393
|
<span {...alert.regionProps} />
|
|
385
394
|
</>
|
|
@@ -100,7 +100,7 @@ export function GroupedPicker({
|
|
|
100
100
|
<ChevronDown className="size-4 shrink-0 text-[var(--text-placeholder)]" />
|
|
101
101
|
</button>
|
|
102
102
|
{open && (
|
|
103
|
-
<div className="absolute
|
|
103
|
+
<div className="absolute start-0 z-30 mt-1 flex max-h-96 w-[min(56rem,85vw)] flex-col overflow-hidden rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg">
|
|
104
104
|
<DropdownSearchHeader
|
|
105
105
|
query={query}
|
|
106
106
|
onQueryChange={setQuery}
|
|
@@ -132,7 +132,7 @@ export function GroupedPicker({
|
|
|
132
132
|
setOpen(false);
|
|
133
133
|
}}
|
|
134
134
|
className={cn(
|
|
135
|
-
"flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-
|
|
135
|
+
"flex w-full items-center gap-1.5 rounded px-1.5 py-0.5 text-start text-sm",
|
|
136
136
|
isSelected
|
|
137
137
|
? "bg-[var(--brand-bg)] font-medium text-[var(--brand-muted)]"
|
|
138
138
|
: "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
-
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
2
|
+
import type { ComponentPropsWithoutRef, KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react";
|
|
3
3
|
import { cn } from "../lib/cn";
|
|
4
|
-
import { useOutsideClick } from "../hooks/use-dismiss";
|
|
4
|
+
import { useEscapeKey, useOutsideClick } from "../hooks/use-dismiss";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Exported and `<div>`-shaped: the top bar composes several of these side by side, and
|
|
@@ -14,13 +14,18 @@ import { useOutsideClick } from "../hooks/use-dismiss";
|
|
|
14
14
|
export interface HoverMenuProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
|
|
15
15
|
trigger: (state: { open: boolean; toggle: () => void }) => ReactNode;
|
|
16
16
|
children: (close: () => void) => ReactNode;
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Which edge of the trigger the panel lines up with. `start`/`end` follow the
|
|
19
|
+
* reading direction (`end` is the right edge in LTR, the left in RTL); `left`/`right`
|
|
20
|
+
* are physical and kept for existing callers. Default `end` — the same right-aligned
|
|
21
|
+
* panel as before in LTR, mirrored in RTL.
|
|
22
|
+
*/
|
|
23
|
+
align?: "start" | "end" | "left" | "right";
|
|
18
24
|
panelClassName?: string;
|
|
19
25
|
/** Extra classes for the WRAPPER (the panel has {@link panelClassName}). */
|
|
20
26
|
className?: string;
|
|
21
27
|
/**
|
|
22
|
-
* @deprecated Use the DOM spelling `aria-label`, which
|
|
23
|
-
* any other attribute. Kept working because keksdose, kastlan and lenkbank all pass
|
|
28
|
+
* @deprecated Use the DOM spelling `aria-label`, which names the `role="menu"` panel. Kept working because keksdose, kastlan and lenkbank all pass
|
|
24
29
|
* this today and a rename to save one line of resolution is not a patch release; it
|
|
25
30
|
* loses to `aria-label` wherever both are given. Removal is a later minor.
|
|
26
31
|
*/
|
|
@@ -35,6 +40,42 @@ const CLOSE_DELAY_MS = 120;
|
|
|
35
40
|
|
|
36
41
|
type Phase = "closed" | "opening" | "open" | "closing";
|
|
37
42
|
|
|
43
|
+
const ALIGN_CLASS = { start: "start-0", end: "end-0", left: "left-0", right: "right-0" } as const;
|
|
44
|
+
|
|
45
|
+
/** Explicit menu-item roles a caller may already have written. */
|
|
46
|
+
const ITEM_ROLES = '[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"]';
|
|
47
|
+
/** What the panel promotes to `menuitem` when the caller wrote plain controls. */
|
|
48
|
+
const PLAIN_ITEMS = "button:not([role]),a[href]:not([role])";
|
|
49
|
+
/** The trigger: the first focusable in the wrapper that is not inside the panel. */
|
|
50
|
+
const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Give the caller's markup menu semantics.
|
|
54
|
+
*
|
|
55
|
+
* `children` is arbitrary — every caller in the kit renders a `<ul>` of `<li>`s holding
|
|
56
|
+
* `<button>`s and router `<Link>`s — so the panel was a `role="menu"` whose children
|
|
57
|
+
* were a list, list items and plain buttons: no `menuitem` anywhere, which is a menu
|
|
58
|
+
* with nothing in it as far as a screen reader is concerned. Rather than make every
|
|
59
|
+
* caller rewrite its rows, the panel upgrades them after render: controls become
|
|
60
|
+
* `menuitem`s out of the Tab order (the arrow keys move between them), and the list
|
|
61
|
+
* markup in between becomes `role="none"` so the menu owns its items directly. An
|
|
62
|
+
* element that already carries a role is the caller's decision and is left alone.
|
|
63
|
+
*/
|
|
64
|
+
function applyMenuSemantics(menu: HTMLElement): HTMLElement[] {
|
|
65
|
+
for (const el of menu.querySelectorAll<HTMLElement>(PLAIN_ITEMS)) el.setAttribute("role", "menuitem");
|
|
66
|
+
const items = Array.from(menu.querySelectorAll<HTMLElement>(ITEM_ROLES));
|
|
67
|
+
for (const item of items) {
|
|
68
|
+
item.setAttribute("tabindex", "-1");
|
|
69
|
+
for (let el = item.parentElement; el && el !== menu; el = el.parentElement) {
|
|
70
|
+
if (!el.hasAttribute("role")) el.setAttribute("role", "none");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const el of menu.querySelectorAll<HTMLElement>("ul:not([role]),ol:not([role]),li:not([role])")) {
|
|
74
|
+
el.setAttribute("role", "none");
|
|
75
|
+
}
|
|
76
|
+
return items.filter((el) => !el.hasAttribute("disabled") && el.getAttribute("aria-disabled") !== "true");
|
|
77
|
+
}
|
|
78
|
+
|
|
38
79
|
// Only one hover menu may show its panel at a time across the app — opening
|
|
39
80
|
// one closes whichever was open, so adjacent top-bar menus can't overlap
|
|
40
81
|
// (feedback #251). Module-level is fine: there is one cursor per document.
|
|
@@ -43,7 +84,7 @@ let activeClose: (() => void) | null = null;
|
|
|
43
84
|
export function HoverMenu({
|
|
44
85
|
trigger,
|
|
45
86
|
children,
|
|
46
|
-
align = "
|
|
87
|
+
align = "end",
|
|
47
88
|
panelClassName,
|
|
48
89
|
className,
|
|
49
90
|
ariaLabel,
|
|
@@ -118,6 +159,87 @@ export function HoverMenu({
|
|
|
118
159
|
|
|
119
160
|
useOutsideClick(wrapperRef, close, open);
|
|
120
161
|
|
|
162
|
+
const menuRef = useRef<HTMLDivElement>(null);
|
|
163
|
+
/** Set by a keyboard press on the trigger, so the open that follows moves focus into
|
|
164
|
+
* the menu. A hover or a mouse click opens it without taking focus off the page. */
|
|
165
|
+
const focusOnOpenRef = useRef<"first" | "last" | null>(null);
|
|
166
|
+
|
|
167
|
+
const triggerEl = () =>
|
|
168
|
+
Array.from(wrapperRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []).find(
|
|
169
|
+
(el) => !menuRef.current?.contains(el),
|
|
170
|
+
) ?? null;
|
|
171
|
+
const items = () => (menuRef.current ? applyMenuSemantics(menuRef.current) : []);
|
|
172
|
+
|
|
173
|
+
// Escape closes, from anywhere — it did nothing at all before. Focus goes back to the
|
|
174
|
+
// trigger only when it was inside the menu: a panel opened by hover while the user is
|
|
175
|
+
// typing elsewhere must not yank the caret over here on the way out.
|
|
176
|
+
useEscapeKey(() => {
|
|
177
|
+
const hadFocus = wrapperRef.current?.contains(document.activeElement) ?? false;
|
|
178
|
+
close();
|
|
179
|
+
if (hadFocus) triggerEl()?.focus();
|
|
180
|
+
}, open);
|
|
181
|
+
|
|
182
|
+
// Upgrade the markup every render while open (the caller's rows can change), then
|
|
183
|
+
// honour a pending keyboard open.
|
|
184
|
+
useLayoutEffect(() => {
|
|
185
|
+
if (!open) return;
|
|
186
|
+
const list = items();
|
|
187
|
+
const want = focusOnOpenRef.current;
|
|
188
|
+
focusOnOpenRef.current = null;
|
|
189
|
+
if (want) (want === "last" ? list[list.length - 1] : list[0])?.focus();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Trigger state for assistive technology, on whatever element the caller rendered.
|
|
193
|
+
// Only where the caller has not said something of its own.
|
|
194
|
+
useLayoutEffect(() => {
|
|
195
|
+
const el = triggerEl();
|
|
196
|
+
if (!el) return;
|
|
197
|
+
if (!el.hasAttribute("aria-haspopup")) el.setAttribute("aria-haspopup", "menu");
|
|
198
|
+
if (el.getAttribute("aria-haspopup") === "menu") el.setAttribute("aria-expanded", String(open));
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
/** ↑/↓ roving focus with wrap, Home/End, Tab leaves (and closes); on the trigger,
|
|
202
|
+
* Enter/Space/↓ open onto the first item and ↑ onto the last. */
|
|
203
|
+
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
204
|
+
const target = e.target as HTMLElement;
|
|
205
|
+
const inMenu = menuRef.current?.contains(target) ?? false;
|
|
206
|
+
if (!inMenu) {
|
|
207
|
+
if (target !== triggerEl()) return;
|
|
208
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
209
|
+
// The trigger's own click handler does the toggling; this only records that
|
|
210
|
+
// the press came from a keyboard.
|
|
211
|
+
if (!open) focusOnOpenRef.current = "first";
|
|
212
|
+
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
213
|
+
e.preventDefault();
|
|
214
|
+
const edge = e.key === "ArrowDown" ? "first" : "last";
|
|
215
|
+
if (open) {
|
|
216
|
+
const list = items();
|
|
217
|
+
(edge === "last" ? list[list.length - 1] : list[0])?.focus();
|
|
218
|
+
} else {
|
|
219
|
+
focusOnOpenRef.current = edge;
|
|
220
|
+
openedByHoverRef.current = false;
|
|
221
|
+
setPhase("open");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (e.key === "Tab") {
|
|
227
|
+
close();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const list = items();
|
|
231
|
+
if (list.length === 0) return;
|
|
232
|
+
const at = list.indexOf(document.activeElement as HTMLElement);
|
|
233
|
+
let next: number | null = null;
|
|
234
|
+
if (e.key === "ArrowDown") next = at < 0 ? 0 : (at + 1) % list.length;
|
|
235
|
+
else if (e.key === "ArrowUp") next = at < 0 ? list.length - 1 : (at - 1 + list.length) % list.length;
|
|
236
|
+
else if (e.key === "Home") next = 0;
|
|
237
|
+
else if (e.key === "End") next = list.length - 1;
|
|
238
|
+
if (next === null) return;
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
list[next].focus();
|
|
241
|
+
};
|
|
242
|
+
|
|
121
243
|
// Keep the panel inside the viewport (feedback #85): a wide panel anchored to a
|
|
122
244
|
// trigger near a screen edge would otherwise spill off it (on mobile the guided-
|
|
123
245
|
// tours menu ran off the left). Once open, measure and translate it horizontally
|
|
@@ -153,9 +275,10 @@ export function HoverMenu({
|
|
|
153
275
|
className={cn("relative", className)}
|
|
154
276
|
onMouseEnter={handleMouseEnter}
|
|
155
277
|
onMouseLeave={handleMouseLeave}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
278
|
+
onKeyDown={(e) => {
|
|
279
|
+
rest.onKeyDown?.(e);
|
|
280
|
+
onKeyDown(e);
|
|
281
|
+
}}
|
|
159
282
|
>
|
|
160
283
|
{trigger({ open, toggle })}
|
|
161
284
|
{open && (
|
|
@@ -163,12 +286,18 @@ export function HoverMenu({
|
|
|
163
286
|
ref={panelRef}
|
|
164
287
|
className={cn(
|
|
165
288
|
"absolute top-full z-40 pt-2",
|
|
166
|
-
align
|
|
289
|
+
ALIGN_CLASS[align],
|
|
167
290
|
)}
|
|
168
291
|
style={shiftX ? { transform: `translateX(${shiftX}px)` } : undefined}
|
|
169
292
|
>
|
|
170
293
|
<div
|
|
294
|
+
ref={menuRef}
|
|
171
295
|
role="menu"
|
|
296
|
+
// On the element with the role. It used to sit on the role-less wrapper,
|
|
297
|
+
// where no screen reader reads a name. The DOM spelling wins;
|
|
298
|
+
// {@link HoverMenuProps.ariaLabel} is the deprecated alias three apps still
|
|
299
|
+
// pass, so it stays as the fallback rather than as the answer.
|
|
300
|
+
aria-label={ariaLabelAttr ?? ariaLabel}
|
|
172
301
|
className={cn(
|
|
173
302
|
"min-w-44 max-w-[calc(100vw-1rem)] rounded-md border border-[var(--border)] bg-[var(--bg-surface)] shadow-lg",
|
|
174
303
|
panelClassName,
|
|
@@ -65,6 +65,15 @@ export interface IconPickerProps<T extends string>
|
|
|
65
65
|
searchable?: boolean;
|
|
66
66
|
/** 28 / 32 / 44px tiles. Default "md"; "lg" is the touch-target size. */
|
|
67
67
|
size?: TileSize;
|
|
68
|
+
/**
|
|
69
|
+
* Classes for every tile's frame, merged after the kit's own — so a caller's
|
|
70
|
+
* `size-*` beats `size`'s. For a tile that has to follow a container query or a
|
|
71
|
+
* breakpoint (keksdose grows its tiles to 40px in a narrow card:
|
|
72
|
+
* `tileClassName="@max-md:size-10"`) without reaching into the kit's markup with an
|
|
73
|
+
* `[&_[role=radio]]` selector. The swatch dot / glyph inside keeps `size`'s
|
|
74
|
+
* dimensions; the tile grows round it.
|
|
75
|
+
*/
|
|
76
|
+
tileClassName?: string;
|
|
68
77
|
disabled?: boolean;
|
|
69
78
|
labels?: Partial<IconPickerLabels>;
|
|
70
79
|
}
|
|
@@ -93,6 +102,7 @@ export function IconPicker<T extends string>({
|
|
|
93
102
|
activation = "automatic",
|
|
94
103
|
searchable = false,
|
|
95
104
|
size = "md",
|
|
105
|
+
tileClassName,
|
|
96
106
|
disabled = false,
|
|
97
107
|
labels,
|
|
98
108
|
className,
|
|
@@ -156,7 +166,10 @@ export function IconPicker<T extends string>({
|
|
|
156
166
|
return Icon ? <Icon aria-hidden className={TILE_SIZE[size].glyph} /> : null;
|
|
157
167
|
}}
|
|
158
168
|
tileClassName={(_item, selected) =>
|
|
159
|
-
|
|
169
|
+
cn(
|
|
170
|
+
selected ? "text-[var(--text-primary)]" : "text-[var(--text-secondary)]",
|
|
171
|
+
tileClassName,
|
|
172
|
+
)
|
|
160
173
|
}
|
|
161
174
|
/>
|
|
162
175
|
</div>
|