@eifi1/ui-kit 0.21.0 → 0.23.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 +42 -25
- package/dist/chart.d.ts +19 -14
- package/dist/components/account-chips.d.ts +10 -3
- package/dist/components/account-chips.js +5 -2
- package/dist/components/account-chips.js.map +1 -1
- package/dist/components/account-settings.d.ts +33 -2
- package/dist/components/account-settings.js +43 -21
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/amount-input.d.ts +53 -14
- package/dist/components/amount-input.js +17 -28
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.d.ts +29 -0
- package/dist/components/autocomplete.js +134 -104
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/button-group.d.ts +19 -14
- package/dist/components/calculator.d.ts +19 -14
- package/dist/components/checkbox-group.d.ts +104 -0
- package/dist/components/checkbox-group.js +111 -0
- package/dist/components/checkbox-group.js.map +1 -0
- package/dist/components/checkbox.d.ts +19 -0
- package/dist/components/checkbox.js +30 -7
- package/dist/components/checkbox.js.map +1 -1
- package/dist/components/chip.d.ts +18 -0
- package/dist/components/chip.js +47 -10
- package/dist/components/chip.js.map +1 -1
- package/dist/components/column-mapper.d.ts +182 -0
- package/dist/components/column-mapper.js +375 -0
- package/dist/components/column-mapper.js.map +1 -0
- package/dist/components/combobox-core.d.ts +39 -2
- package/dist/components/combobox-core.js +29 -6
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +91 -2
- package/dist/components/combobox.js +454 -308
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +69 -161
- package/dist/components/confirm-dialog.js +7 -8
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.d.ts +16 -11
- package/dist/components/country-select.d.ts +167 -0
- package/dist/components/country-select.js +293 -0
- package/dist/components/country-select.js.map +1 -0
- package/dist/components/currency-select.d.ts +17 -1
- package/dist/components/currency-select.js +56 -23
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +69 -166
- package/dist/components/danger-confirm.js +144 -50
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-cells.d.ts +1 -2
- package/dist/components/data-table-filter-popover.d.ts +1 -2
- package/dist/components/data-table-filters.d.ts +1 -2
- package/dist/components/data-table-labels.d.ts +4 -97
- package/dist/components/data-table-labels.js +9 -1
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table-pagination.d.ts +3 -1
- package/dist/components/data-table.d.ts +1 -2
- package/dist/components/data-table.js +98 -3
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.d.ts +66 -5
- package/dist/components/date-picker.js +378 -66
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +42 -1
- package/dist/components/entity-combobox.js +154 -113
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/facing-pair.d.ts +19 -14
- package/dist/components/field-parts.d.ts +224 -0
- package/dist/components/field-parts.js +156 -0
- package/dist/components/field-parts.js.map +1 -0
- package/dist/components/field-strip.d.ts +105 -0
- package/dist/components/field-strip.js +46 -0
- package/dist/components/field-strip.js.map +1 -0
- package/dist/components/file-button.d.ts +19 -14
- package/dist/components/file-button.js +13 -3
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.d.ts +41 -15
- package/dist/components/file-dropzone.js +57 -34
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/form-actions.d.ts +18 -13
- package/dist/components/form-actions.js +69 -5
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/iban-input.d.ts +69 -0
- package/dist/components/iban-input.js +125 -0
- package/dist/components/iban-input.js.map +1 -0
- package/dist/components/icon-picker.d.ts +21 -2
- package/dist/components/icon-picker.js +19 -4
- package/dist/components/icon-picker.js.map +1 -1
- package/dist/components/inline-edit-field.d.ts +167 -0
- package/dist/components/inline-edit-field.js +293 -0
- package/dist/components/inline-edit-field.js.map +1 -0
- package/dist/components/language-select.d.ts +130 -0
- package/dist/components/language-select.js +18 -0
- package/dist/components/language-select.js.map +1 -0
- package/dist/components/line-items.d.ts +25 -3
- package/dist/components/line-items.js +55 -46
- package/dist/components/line-items.js.map +1 -1
- package/dist/components/list.d.ts +25 -0
- package/dist/components/list.js +16 -5
- package/dist/components/list.js.map +1 -1
- package/dist/components/money-field.d.ts +32 -14
- package/dist/components/money-field.js.map +1 -1
- package/dist/components/month-picker.d.ts +52 -4
- package/dist/components/month-picker.js +291 -86
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +26 -1
- package/dist/components/multi-entity-combobox.js +143 -102
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.d.ts +8 -1
- package/dist/components/multi-select.js +9 -4
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/number-field.d.ts +34 -19
- package/dist/components/number-field.js +6 -20
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +79 -19
- package/dist/components/number-input.js +90 -38
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +19 -14
- package/dist/components/one-time-code-input.d.ts +100 -0
- package/dist/components/one-time-code-input.js +137 -0
- package/dist/components/one-time-code-input.js.map +1 -0
- package/dist/components/phone-input.d.ts +69 -0
- package/dist/components/phone-input.js +171 -0
- package/dist/components/phone-input.js.map +1 -0
- package/dist/components/reauth-dialog.d.ts +2 -2
- package/dist/components/reauth-dialog.js +6 -8
- package/dist/components/reauth-dialog.js.map +1 -1
- package/dist/components/series-chart.d.ts +19 -14
- package/dist/components/settings-fields.d.ts +19 -14
- package/dist/components/share-card.d.ts +16 -11
- package/dist/components/sign-chip.d.ts +66 -0
- package/dist/components/sign-chip.js +39 -0
- package/dist/components/sign-chip.js.map +1 -0
- package/dist/components/swatch-picker.d.ts +43 -2
- package/dist/components/swatch-picker.js +80 -32
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/switch.d.ts +19 -0
- package/dist/components/switch.js +24 -6
- package/dist/components/switch.js.map +1 -1
- package/dist/components/text-link.d.ts +18 -13
- package/dist/components/tile-radio.d.ts +88 -13
- package/dist/components/tile-radio.js +28 -11
- package/dist/components/tile-radio.js.map +1 -1
- package/dist/components/time-input.d.ts +24 -15
- package/dist/components/time-input.js.map +1 -1
- package/dist/components/toggle-group.d.ts +47 -1
- package/dist/components/toggle-group.js +110 -69
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/trigger-aria.d.ts +3 -0
- package/dist/components/trigger-aria.js +3 -1
- package/dist/components/trigger-aria.js.map +1 -1
- package/dist/components/ui.d.ts +16 -11
- package/dist/components/ui.js +284 -88
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -2
- package/dist/{data-table-3mi2R3so.d.ts → data-table-labels-B7OdnM0S.d.ts} +140 -3
- package/dist/data-table.d.ts +1 -2
- package/dist/data-table.js +2 -1
- package/dist/data-table.js.map +1 -1
- package/dist/feedback/feedback-attachment.d.ts +1 -1
- package/dist/feedback/feedback-attachment.js +348 -41
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +1 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +3 -2
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +68 -253
- package/dist/feedback/feedback-thread.js +46 -10
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/{kit-labels-D1J2_jyD.d.ts → feedback-BxeQVzwq.d.ts} +1206 -20
- package/dist/{feedback-attachment-WFiXJ8We.d.ts → feedback-attachment-fGAzZPf0.d.ts} +178 -11
- package/dist/feedback.d.ts +66 -2
- package/dist/hooks/use-file-drop.d.ts +19 -14
- package/dist/i18n/defaults.d.ts +19 -14
- package/dist/i18n/defaults.js +19 -2
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +19 -14
- package/dist/i18n/german.js +84 -5
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +15 -10
- package/dist/i18n/kit-labels.js +8 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +19 -14
- package/dist/i18n/locales/de-CH.d.ts +19 -14
- package/dist/i18n/locales/en.d.ts +19 -14
- package/dist/i18n/locales/en.js +16 -0
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +19 -14
- package/dist/i18n/locales/es.js +83 -5
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +19 -14
- package/dist/i18n/locales/fr.js +82 -5
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +19 -14
- package/dist/i18n/locales/hu.js +85 -5
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +19 -14
- package/dist/i18n/locales/it.js +84 -5
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +19 -14
- package/dist/i18n/locales/zh.js +80 -5
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +19 -14
- package/dist/i18n/review.js +27 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +21 -10
- package/dist/index.js +73 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/column-mapping.d.ts +81 -0
- package/dist/lib/column-mapping.js +108 -0
- package/dist/lib/column-mapping.js.map +1 -0
- package/dist/lib/countries.d.ts +43 -0
- package/dist/lib/countries.js +39 -0
- package/dist/lib/countries.js.map +1 -0
- package/dist/lib/decimal-marks.d.ts +46 -0
- package/dist/lib/decimal-marks.js +40 -0
- package/dist/lib/decimal-marks.js.map +1 -0
- package/dist/lib/iban.d.ts +98 -0
- package/dist/lib/iban.js +154 -0
- package/dist/lib/iban.js.map +1 -0
- package/dist/lib/isin.d.ts +30 -0
- package/dist/lib/isin.js +36 -0
- package/dist/lib/isin.js.map +1 -0
- package/dist/lib/phone.d.ts +69 -0
- package/dist/lib/phone.js +121 -0
- package/dist/lib/phone.js.map +1 -0
- package/dist/lib/table-text.d.ts +109 -1
- package/dist/lib/table-text.js +122 -1
- package/dist/lib/table-text.js.map +1 -1
- package/dist/rhf/fields.d.ts +233 -18
- package/dist/rhf/fields.js +456 -3
- package/dist/rhf/fields.js.map +1 -1
- package/dist/rhf/form.d.ts +19 -14
- package/dist/rhf.d.ts +22 -15
- package/dist/rhf.js.map +1 -1
- package/dist/shell/app-shell.d.ts +18 -13
- package/dist/shell/top-bar-brand.d.ts +19 -14
- package/dist/shell/topbar-action-menu.d.ts +36 -3
- package/dist/shell/topbar-action-menu.js +74 -33
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +18 -13
- package/dist/table-text.d.ts +1 -1
- package/dist/tour/tour.js +20 -24
- package/dist/tour/tour.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +49 -16
- package/dist/wizard/stepper-nav.js +4 -0
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard.d.ts +19 -14
- package/package.json +1 -1
- package/src/components/account-chips.tsx +17 -3
- package/src/components/account-settings.tsx +92 -26
- package/src/components/amount-input.tsx +68 -56
- package/src/components/autocomplete.tsx +198 -114
- package/src/components/checkbox-group.tsx +229 -0
- package/src/components/checkbox.tsx +72 -8
- package/src/components/chip.tsx +80 -10
- package/src/components/column-mapper.tsx +666 -0
- package/src/components/combobox-core.tsx +82 -9
- package/src/components/combobox.tsx +661 -340
- package/src/components/confirm-dialog.tsx +12 -8
- package/src/components/country-select.tsx +556 -0
- package/src/components/currency-select.tsx +109 -33
- package/src/components/danger-confirm.tsx +429 -82
- package/src/components/data-table-labels.ts +16 -1
- package/src/components/data-table.tsx +175 -6
- package/src/components/date-picker.tsx +595 -71
- package/src/components/entity-combobox.tsx +235 -124
- package/src/components/field-parts.tsx +403 -0
- package/src/components/field-strip.tsx +149 -0
- package/src/components/file-button.tsx +36 -3
- package/src/components/file-dropzone.tsx +106 -38
- package/src/components/form-actions.tsx +147 -6
- package/src/components/iban-input.tsx +231 -0
- package/src/components/icon-picker.tsx +47 -6
- package/src/components/inline-edit-field.tsx +533 -0
- package/src/components/language-select.tsx +78 -0
- package/src/components/line-items.tsx +35 -2
- package/src/components/list.tsx +55 -6
- package/src/components/money-field.tsx +13 -0
- package/src/components/month-picker.tsx +400 -63
- package/src/components/multi-entity-combobox.tsx +207 -120
- package/src/components/multi-select.tsx +80 -58
- package/src/components/number-field.tsx +28 -46
- package/src/components/number-input.tsx +210 -43
- package/src/components/one-time-code-input.tsx +288 -0
- package/src/components/phone-input.tsx +299 -0
- package/src/components/reauth-dialog.tsx +17 -18
- package/src/components/sign-chip.tsx +103 -0
- package/src/components/swatch-picker.tsx +143 -31
- package/src/components/switch.tsx +58 -7
- package/src/components/tile-radio.tsx +145 -29
- package/src/components/time-input.tsx +8 -1
- package/src/components/toggle-group.tsx +206 -73
- package/src/components/trigger-aria.ts +5 -0
- package/src/components/ui.tsx +479 -138
- package/src/data-table.ts +2 -1
- package/src/feedback/feedback-attachment.tsx +597 -46
- package/src/feedback/feedback-dialog.tsx +9 -3
- package/src/feedback/feedback-inbox.tsx +3 -2
- package/src/feedback/feedback-thread.tsx +113 -9
- package/src/i18n/defaults.ts +18 -1
- package/src/i18n/german.ts +89 -0
- package/src/i18n/kit-labels.tsx +82 -2
- package/src/i18n/locales/en.ts +36 -4
- package/src/i18n/locales/es.ts +87 -0
- package/src/i18n/locales/fr.ts +87 -0
- package/src/i18n/locales/hu.ts +83 -0
- package/src/i18n/locales/it.ts +88 -0
- package/src/i18n/locales/zh.ts +76 -0
- package/src/i18n/review.ts +26 -0
- package/src/index.ts +71 -3
- package/src/lib/column-mapping.ts +234 -0
- package/src/lib/countries.ts +91 -0
- package/src/lib/decimal-marks.ts +99 -0
- package/src/lib/iban.ts +171 -0
- package/src/lib/isin.ts +62 -0
- package/src/lib/phone.ts +211 -0
- package/src/lib/table-text.ts +271 -0
- package/src/rhf/fields.tsx +860 -9
- package/src/rhf.ts +3 -1
- package/src/shell/topbar-action-menu.tsx +134 -38
- package/src/tour/tour.tsx +66 -28
- package/src/wizard/stepper-nav.tsx +35 -1
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
import type { FilePickHandler, FilePickerLabels, FileRejection } from "./file-button";
|
|
16
16
|
import { useDragTarget } from "../hooks/use-file-drop";
|
|
17
17
|
import { toast } from "./toast";
|
|
18
|
+
import { useCommitReason } from "./write-lock";
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Where a refused file's message goes.
|
|
@@ -35,6 +36,11 @@ export interface FileDropzoneState {
|
|
|
35
36
|
dragOver: boolean;
|
|
36
37
|
disabled: boolean;
|
|
37
38
|
busy: boolean;
|
|
39
|
+
/** 0.22: locked with a reason (`disabledReason`, or `commit` under a locked
|
|
40
|
+
* {@link WriteLockProvider}) — takes no files, like `disabled`. */
|
|
41
|
+
locked: boolean;
|
|
42
|
+
/** The reason while `locked`, for a custom body to show; `undefined` otherwise. */
|
|
43
|
+
disabledReason: ReactNode | undefined;
|
|
38
44
|
/** What is chosen: `files` in multiple mode, `[file]` or `[]` in single mode. */
|
|
39
45
|
files: readonly File[];
|
|
40
46
|
/** The inline refusal, when `rejectionFeedback="inline"` has one to show. */
|
|
@@ -139,6 +145,22 @@ export interface FileDropzoneProps extends Omit<ComponentPropsWithoutRef<"div">,
|
|
|
139
145
|
* text. The other half of why keksdose's invoice zone was hand-rolled.
|
|
140
146
|
*/
|
|
141
147
|
busy?: boolean;
|
|
148
|
+
/**
|
|
149
|
+
* Why the zone is not taking files — the write lock's sentence, a quota, a closed
|
|
150
|
+
* period (keksdose K3). Takes no drop, no picker and no removal, like `disabled`, and
|
|
151
|
+
* SAYS so: the Browse button (and every remove button) stays focusable,
|
|
152
|
+
* `aria-disabled`, with the reason in the kit Tooltip and its description, the zone's
|
|
153
|
+
* group is described by it, and the default body shows it where the hint was. Not
|
|
154
|
+
* dimmed: what is chosen stays readable. Wins over `disabled`'s silent look.
|
|
155
|
+
*/
|
|
156
|
+
disabledReason?: ReactNode;
|
|
157
|
+
/**
|
|
158
|
+
* This zone COMMITS — an upload that starts on drop. Under a locked
|
|
159
|
+
* {@link WriteLockProvider} it takes the `disabledReason` path with the lock's
|
|
160
|
+
* reason (which wins over one of its own). No provider, or an unlocked one: no
|
|
161
|
+
* effect. A zone that only stages a file for a form saved later is not a commit.
|
|
162
|
+
*/
|
|
163
|
+
commit?: boolean;
|
|
142
164
|
/**
|
|
143
165
|
* Replace the zone's body — the icon, the text and the chosen-file list — with your
|
|
144
166
|
* own, given the zone's state: keksdose's spinner-plus-"uploading", its multi-shot
|
|
@@ -182,6 +204,8 @@ export function FileDropzone({
|
|
|
182
204
|
hint: hintProp,
|
|
183
205
|
disabled,
|
|
184
206
|
busy,
|
|
207
|
+
disabledReason: ownDisabledReason,
|
|
208
|
+
commit,
|
|
185
209
|
renderBody,
|
|
186
210
|
className,
|
|
187
211
|
"aria-label": ariaLabel,
|
|
@@ -189,7 +213,12 @@ export function FileDropzone({
|
|
|
189
213
|
...rest
|
|
190
214
|
}: FileDropzoneProps) {
|
|
191
215
|
const [error, setError] = useState<string | null>(null);
|
|
192
|
-
const
|
|
216
|
+
const disabledReason = useCommitReason(commit, ownDisabledReason);
|
|
217
|
+
const locked = hasReason(disabledReason);
|
|
218
|
+
// Locked counts as inert for every file path (drop, click, Browse, remove); only the
|
|
219
|
+
// LOOK and the native `disabled` keep it apart, so its controls stay focusable.
|
|
220
|
+
const inert = Boolean(disabled || busy) || locked;
|
|
221
|
+
const reasonId = useId();
|
|
193
222
|
// State rather than a ref: `open` is handed to `renderBody` during render, and a
|
|
194
223
|
// function that reads a ref there is exactly what the refs rule cannot prove safe.
|
|
195
224
|
const [fileInput, setFileInput] = useState<HTMLInputElement | null>(null);
|
|
@@ -278,7 +307,16 @@ export function FileDropzone({
|
|
|
278
307
|
|
|
279
308
|
const chosen: readonly File[] = multiple ? (files ?? []) : file ? [file] : [];
|
|
280
309
|
const showError = feedback === "inline" && error !== null;
|
|
281
|
-
const describedBy = cn(describedByProp, showError && errorId) || undefined;
|
|
310
|
+
const describedBy = cn(describedByProp, showError && errorId, locked && reasonId) || undefined;
|
|
311
|
+
// The controls inside: natively disabled while disabled or busy; locked, they take
|
|
312
|
+
// the reason instead and stay reachable (Button's `disabledReason` path).
|
|
313
|
+
const controlDisabled = Boolean(disabled || busy);
|
|
314
|
+
const controlReason = locked ? disabledReason : undefined;
|
|
315
|
+
const reasonLine = locked && (
|
|
316
|
+
<div data-slot="file-dropzone-reason" className="text-xs text-[var(--text-muted)]">
|
|
317
|
+
{disabledReason}
|
|
318
|
+
</div>
|
|
319
|
+
);
|
|
282
320
|
|
|
283
321
|
return (
|
|
284
322
|
<>
|
|
@@ -311,7 +349,7 @@ export function FileDropzone({
|
|
|
311
349
|
// reached through the description instead, and spoken when it appears.
|
|
312
350
|
aria-describedby={describedBy}
|
|
313
351
|
data-invalid={showError || undefined}
|
|
314
|
-
aria-disabled={disabled || undefined}
|
|
352
|
+
aria-disabled={disabled || locked || undefined}
|
|
315
353
|
aria-busy={busy || undefined}
|
|
316
354
|
data-drag-over={dragOver || undefined}
|
|
317
355
|
className={cn(
|
|
@@ -336,7 +374,9 @@ export function FileDropzone({
|
|
|
336
374
|
? "cursor-not-allowed border-[var(--border)] opacity-60"
|
|
337
375
|
: busy
|
|
338
376
|
? "cursor-progress border-[var(--border)]"
|
|
339
|
-
:
|
|
377
|
+
: locked
|
|
378
|
+
? "cursor-not-allowed border-[var(--border)]"
|
|
379
|
+
: dragOver
|
|
340
380
|
? "cursor-pointer border-[var(--border-strong)] bg-[var(--bg-active)]"
|
|
341
381
|
: showError
|
|
342
382
|
? "cursor-pointer border-[var(--danger-border-strong)]"
|
|
@@ -349,6 +389,8 @@ export function FileDropzone({
|
|
|
349
389
|
dragOver,
|
|
350
390
|
disabled: Boolean(disabled),
|
|
351
391
|
busy: Boolean(busy),
|
|
392
|
+
locked,
|
|
393
|
+
disabledReason: locked ? disabledReason : undefined,
|
|
352
394
|
files: chosen,
|
|
353
395
|
error: showError ? error : null,
|
|
354
396
|
open,
|
|
@@ -365,34 +407,38 @@ export function FileDropzone({
|
|
|
365
407
|
<>
|
|
366
408
|
<Upload aria-hidden className="size-5 text-[var(--text-muted)]" />
|
|
367
409
|
{multiple && chosen.length > 0 ? (
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
410
|
+
<>
|
|
411
|
+
<ul className="flex w-full max-w-sm flex-col gap-1 text-start">
|
|
412
|
+
{chosen.map((f, i) => (
|
|
413
|
+
<li
|
|
414
|
+
// Name + size + position: two files may share a name, and the index
|
|
415
|
+
// alone would re-key every row below a removal.
|
|
416
|
+
key={`${f.name}-${f.size}-${i}`}
|
|
417
|
+
className="flex items-center gap-2 text-sm text-[var(--text-secondary)]"
|
|
418
|
+
>
|
|
419
|
+
<span className="min-w-0 flex-1 truncate font-medium">{f.name}</span>
|
|
420
|
+
<span className="shrink-0 text-xs text-[var(--text-muted)]">{fileText.size(f.size)}</span>
|
|
421
|
+
{onRemove && (
|
|
422
|
+
<IconButton
|
|
423
|
+
type="button"
|
|
424
|
+
size="sm"
|
|
425
|
+
disabled={controlDisabled}
|
|
426
|
+
disabledReason={controlReason}
|
|
427
|
+
aria-label={labels.remove(f.name)}
|
|
428
|
+
onClick={(e) => {
|
|
429
|
+
e.stopPropagation();
|
|
430
|
+
onRemove(f, i);
|
|
431
|
+
afterRemoval(labels.removed(f.name));
|
|
432
|
+
}}
|
|
433
|
+
>
|
|
434
|
+
<X aria-hidden />
|
|
435
|
+
</IconButton>
|
|
436
|
+
)}
|
|
437
|
+
</li>
|
|
438
|
+
))}
|
|
439
|
+
</ul>
|
|
440
|
+
{reasonLine}
|
|
441
|
+
</>
|
|
396
442
|
) : (
|
|
397
443
|
<>
|
|
398
444
|
<div className="flex items-center gap-1 text-sm text-[var(--text-secondary)]">
|
|
@@ -403,7 +449,8 @@ export function FileDropzone({
|
|
|
403
449
|
<IconButton
|
|
404
450
|
type="button"
|
|
405
451
|
size="sm"
|
|
406
|
-
disabled={
|
|
452
|
+
disabled={controlDisabled}
|
|
453
|
+
disabledReason={controlReason}
|
|
407
454
|
aria-label={labels.remove(file.name)}
|
|
408
455
|
onClick={(e) => {
|
|
409
456
|
e.stopPropagation();
|
|
@@ -415,9 +462,16 @@ export function FileDropzone({
|
|
|
415
462
|
</IconButton>
|
|
416
463
|
)}
|
|
417
464
|
</div>
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
465
|
+
{/* Locked, the reason takes the hint's place: what may be dropped is moot
|
|
466
|
+
while nothing may. A chosen file keeps its size, the reason below. */}
|
|
467
|
+
{locked && !(file && !multiple) ? (
|
|
468
|
+
reasonLine
|
|
469
|
+
) : (
|
|
470
|
+
<div className="text-xs text-[var(--text-muted)]">
|
|
471
|
+
{file && !multiple ? fileText.size(file.size) : hint}
|
|
472
|
+
</div>
|
|
473
|
+
)}
|
|
474
|
+
{locked && file && !multiple && reasonLine}
|
|
421
475
|
</>
|
|
422
476
|
)}
|
|
423
477
|
</>
|
|
@@ -427,7 +481,8 @@ export function FileDropzone({
|
|
|
427
481
|
ref={browseRef}
|
|
428
482
|
type="button"
|
|
429
483
|
variant="secondary"
|
|
430
|
-
disabled={
|
|
484
|
+
disabled={controlDisabled}
|
|
485
|
+
disabledReason={controlReason}
|
|
431
486
|
onClick={(e) => {
|
|
432
487
|
e.stopPropagation();
|
|
433
488
|
open();
|
|
@@ -439,7 +494,8 @@ export function FileDropzone({
|
|
|
439
494
|
<Button
|
|
440
495
|
type="button"
|
|
441
496
|
variant="ghost"
|
|
442
|
-
disabled={
|
|
497
|
+
disabled={controlDisabled}
|
|
498
|
+
disabledReason={controlReason}
|
|
443
499
|
onClick={(e) => {
|
|
444
500
|
e.stopPropagation();
|
|
445
501
|
onClear();
|
|
@@ -455,6 +511,13 @@ export function FileDropzone({
|
|
|
455
511
|
{error}
|
|
456
512
|
</p>
|
|
457
513
|
)}
|
|
514
|
+
{/* The group's description while locked: a copy of its own, so a custom body
|
|
515
|
+
that does not show the reason still has the zone say it. */}
|
|
516
|
+
{locked && (
|
|
517
|
+
<span id={reasonId} hidden>
|
|
518
|
+
{disabledReason}
|
|
519
|
+
</span>
|
|
520
|
+
)}
|
|
458
521
|
<input
|
|
459
522
|
ref={setFileInput}
|
|
460
523
|
type="file"
|
|
@@ -484,6 +547,11 @@ export function FileDropzone({
|
|
|
484
547
|
);
|
|
485
548
|
}
|
|
486
549
|
|
|
550
|
+
/** A reason that is really there — `null`, `false` and `""` are no reason. */
|
|
551
|
+
function hasReason(reason: ReactNode): boolean {
|
|
552
|
+
return reason !== undefined && reason !== null && reason !== false && reason !== "";
|
|
553
|
+
}
|
|
554
|
+
|
|
487
555
|
/** A click that landed on a control inside the zone (a custom body's own button, a
|
|
488
556
|
* link in the hint) is that control's, not a request to open the picker. */
|
|
489
557
|
function fromControl(e: MouseEvent<HTMLElement>): boolean {
|
|
@@ -1,22 +1,35 @@
|
|
|
1
|
-
import { isValidElement } from "react";
|
|
2
|
-
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
1
|
+
import { isValidElement, useCallback, useEffect, useRef } from "react";
|
|
2
|
+
import type { ComponentPropsWithoutRef, ReactNode, Ref } from "react";
|
|
3
3
|
import type { LucideIcon } from "lucide-react";
|
|
4
4
|
import { cn } from "../lib/cn";
|
|
5
5
|
import { useKitLabels } from "../i18n/kit-labels";
|
|
6
6
|
import { useMediaQuery } from "../hooks/use-media-query";
|
|
7
|
-
import {
|
|
7
|
+
import { isApplePlatform } from "../hooks/use-hotkey";
|
|
8
|
+
import { Button, Spinner, type ButtonProps, type ButtonSize, type ButtonVariant } from "./ui";
|
|
9
|
+
import { useWriteLock } from "./write-lock";
|
|
8
10
|
|
|
9
11
|
/** The words {@link FormActions} renders on its own behalf — the `form` namespace. */
|
|
10
12
|
export interface FormActionsLabels {
|
|
11
13
|
save: string;
|
|
12
14
|
cancel: string;
|
|
15
|
+
/**
|
|
16
|
+
* The {@link FormActionsProps.submitShortcut} hint beside Save's text, when
|
|
17
|
+
* {@link FormActionsProps.submitShortcutHint} shows it. `apple` is true on a Mac,
|
|
18
|
+
* iPhone or iPad, where the key is Cmd: "⌘ Enter" there, "Ctrl+Enter" elsewhere
|
|
19
|
+
* (a German keyboard says "Strg").
|
|
20
|
+
*/
|
|
21
|
+
submitShortcut: (apple: boolean) => string;
|
|
13
22
|
}
|
|
14
23
|
|
|
15
24
|
export const DEFAULT_FORM_ACTIONS_LABELS: FormActionsLabels = {
|
|
16
25
|
save: "Save",
|
|
17
26
|
cancel: "Cancel",
|
|
27
|
+
submitShortcut: (apple) => (apple ? "⌘ Enter" : "Ctrl+Enter"),
|
|
18
28
|
};
|
|
19
29
|
|
|
30
|
+
/** See {@link FormActionsProps.submitShortcut}. */
|
|
31
|
+
export type FormActionsSubmitShortcut = "mod-enter";
|
|
32
|
+
|
|
20
33
|
export type FormActionsAlign = "start" | "center" | "end" | "between";
|
|
21
34
|
|
|
22
35
|
/**
|
|
@@ -173,6 +186,27 @@ export interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">,
|
|
|
173
186
|
/** The save button's variant. Default `brand`; `danger` for a save that destroys
|
|
174
187
|
* (kastlan's `destructive` flag on its own FormActions). */
|
|
175
188
|
submitVariant?: ButtonVariant;
|
|
189
|
+
/**
|
|
190
|
+
* The Cancel button's variant. Default `secondary` (the outlined button, as before).
|
|
191
|
+
* `ghost` for a row where Cancel should step back further than an outline — keksdose
|
|
192
|
+
* G3: a quiet inline row (a confirm under a table row, a card's own small editor)
|
|
193
|
+
* whose Cancel was a ghost button before it moved onto FormActions, which drew every
|
|
194
|
+
* Cancel outlined.
|
|
195
|
+
*/
|
|
196
|
+
cancelVariant?: ButtonVariant;
|
|
197
|
+
/**
|
|
198
|
+
* The size of every button the row draws itself — Save, Cancel and the
|
|
199
|
+
* `{ label, onClick }` form of {@link destructive}: Button's `size`. Default `md`.
|
|
200
|
+
* `sm` (12px text, `px-2 py-1`) for a dense row: a confirm inside a table's expansion
|
|
201
|
+
* row, a toolbar card. keksdose G3: two such rows had small hand-built buttons and lost
|
|
202
|
+
* them on adopting FormActions, which had no size. At `sm` the spinner and
|
|
203
|
+
* {@link submitIcon} shrink with the text.
|
|
204
|
+
*
|
|
205
|
+
* Your own elements (`children`, an element passed as `destructive`, `start`) are
|
|
206
|
+
* yours to size — pass them `size="sm"` too. A `size` in {@link submitProps} still
|
|
207
|
+
* wins for Save, as it always has.
|
|
208
|
+
*/
|
|
209
|
+
size?: ButtonSize;
|
|
176
210
|
/**
|
|
177
211
|
* A destructive action at the START of the row, apart from Save and Cancel — the
|
|
178
212
|
* "Delete" of an edit dialog. Pass `{ label, onClick }` for the kit's quiet danger
|
|
@@ -229,10 +263,60 @@ export interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">,
|
|
|
229
263
|
/** The `id` of the `<form>` the save button submits, when the row is rendered
|
|
230
264
|
* outside it (a dialog's footer slot). */
|
|
231
265
|
form?: string;
|
|
266
|
+
/**
|
|
267
|
+
* A keyboard shortcut that presses Save from anywhere in the form. `"mod-enter"`:
|
|
268
|
+
* Ctrl+Enter, or Cmd+Enter on a Mac — both are accepted everywhere, as keksdose's
|
|
269
|
+
* editors always have. keksdose K14 (inputs audit 2026-10-02, S5): its transaction
|
|
270
|
+
* editor, category editor and create card each wrote this as an `onKeyDown` on a
|
|
271
|
+
* container, re-stating `!pending && !saveDisabled && !lock.locked` every time, and
|
|
272
|
+
* the one that forgot the lock let the demo's refused save through from the keyboard.
|
|
273
|
+
*
|
|
274
|
+
* What it does is what pressing Save does — the same button is clicked, so a form
|
|
275
|
+
* submits with its validation and its submitter, and `onSubmit` is called in the
|
|
276
|
+
* `type="button"` mode — and it does NOTHING while Save could not be pressed:
|
|
277
|
+
* `pending`, `submitDisabled`, or a `commit` under a locked {@link WriteLockProvider}.
|
|
278
|
+
*
|
|
279
|
+
* Scoped to the form Save submits (the `form` prop's, else the enclosing `<form>`):
|
|
280
|
+
* the chord counts only when the focus is inside it, so two editors on one page each
|
|
281
|
+
* answer for their own fields. Without a form (an `onSubmit` row in a plain container)
|
|
282
|
+
* the scope is the nearest `role="dialog"`, else the row's parent element — the
|
|
283
|
+
* container the row ends.
|
|
284
|
+
*
|
|
285
|
+
* Listened for on the document, so it runs AFTER every React handler: a field that
|
|
286
|
+
* takes Ctrl+Enter for itself calls `preventDefault()` or `stopPropagation()` and
|
|
287
|
+
* the row stays out of it (keksdose live #202 — the create card's Ctrl+Shift+Enter
|
|
288
|
+
* must create ONE row; the chord here is exactly Ctrl/Cmd+Enter, never with Shift or
|
|
289
|
+
* Alt). Not during IME composition, and not on a held key's repeats.
|
|
290
|
+
*
|
|
291
|
+
* The save button announces it with `aria-keyshortcuts`; {@link submitShortcutHint}
|
|
292
|
+
* also shows it.
|
|
293
|
+
*/
|
|
294
|
+
submitShortcut?: FormActionsSubmitShortcut;
|
|
295
|
+
/** Show the {@link submitShortcut} beside Save's text ("Ctrl+Enter", "⌘ Enter"; the
|
|
296
|
+
* `form.submitShortcut` label), dimmed. Hidden on a touch-only device, which has no
|
|
297
|
+
* keyboard to press it on. Default `false`. */
|
|
298
|
+
submitShortcutHint?: boolean;
|
|
232
299
|
/** Extra actions, placed before Cancel. */
|
|
233
300
|
children?: ReactNode;
|
|
234
301
|
}
|
|
235
302
|
|
|
303
|
+
/** Hand `el` to a caller's ref, whichever kind it is. */
|
|
304
|
+
function assignRef<E>(ref: Ref<E> | undefined, el: E | null): void {
|
|
305
|
+
if (typeof ref === "function") ref(el);
|
|
306
|
+
else if (ref) (ref as { current: E | null }).current = el;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Where {@link FormActionsProps.submitShortcut} listens: the form Save submits (named
|
|
311
|
+
* by `formId`, else the enclosing one), else the nearest dialog, else the row's parent.
|
|
312
|
+
*/
|
|
313
|
+
function shortcutScope(row: HTMLElement | null, formId: string | undefined): Element | null {
|
|
314
|
+
if (!row) return null;
|
|
315
|
+
const named = formId ? row.ownerDocument.getElementById(formId) : null;
|
|
316
|
+
if (named instanceof HTMLFormElement) return named;
|
|
317
|
+
return row.closest("form") ?? row.closest('[role="dialog"]') ?? row.parentElement;
|
|
318
|
+
}
|
|
319
|
+
|
|
236
320
|
function isDestructiveData(value: unknown): value is FormActionsDestructive {
|
|
237
321
|
return typeof value === "object" && value !== null && !isValidElement(value) && "onClick" in value;
|
|
238
322
|
}
|
|
@@ -265,6 +349,8 @@ export function FormActions({
|
|
|
265
349
|
submitDisabledReason,
|
|
266
350
|
commit = false,
|
|
267
351
|
submitVariant = "brand",
|
|
352
|
+
cancelVariant = "secondary",
|
|
353
|
+
size = "md",
|
|
268
354
|
destructive,
|
|
269
355
|
start: startSlot,
|
|
270
356
|
align,
|
|
@@ -272,12 +358,47 @@ export function FormActions({
|
|
|
272
358
|
stickyWithin = "viewport",
|
|
273
359
|
bleed,
|
|
274
360
|
form,
|
|
361
|
+
submitShortcut,
|
|
362
|
+
submitShortcutHint = false,
|
|
275
363
|
children,
|
|
276
364
|
className,
|
|
277
365
|
style,
|
|
278
366
|
...rest
|
|
279
367
|
}: FormActionsProps) {
|
|
280
368
|
const labels = useKitLabels("form", DEFAULT_FORM_ACTIONS_LABELS);
|
|
369
|
+
const lock = useWriteLock();
|
|
370
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
371
|
+
const saveRef = useRef<HTMLButtonElement | null>(null);
|
|
372
|
+
// The caller's own `submitProps.ref` still gets the element.
|
|
373
|
+
const callerSaveRef = submitProps?.ref;
|
|
374
|
+
const setSaveRef = useCallback(
|
|
375
|
+
(el: HTMLButtonElement | null) => {
|
|
376
|
+
saveRef.current = el;
|
|
377
|
+
assignRef(callerSaveRef, el);
|
|
378
|
+
},
|
|
379
|
+
[callerSaveRef],
|
|
380
|
+
);
|
|
381
|
+
// Read by the shortcut's listener at the moment of the key press, so the listener
|
|
382
|
+
// itself is attached once and never sees a stale `pending`.
|
|
383
|
+
const canSave = !pending && !submitDisabled && !(commit && lock.locked);
|
|
384
|
+
const canSaveRef = useRef(canSave);
|
|
385
|
+
useEffect(() => {
|
|
386
|
+
canSaveRef.current = canSave;
|
|
387
|
+
});
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
if (submitShortcut !== "mod-enter") return;
|
|
390
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
391
|
+
if (e.key !== "Enter" || !(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) return;
|
|
392
|
+
if (e.repeat || e.isComposing || e.defaultPrevented || !canSaveRef.current) return;
|
|
393
|
+
const scope = shortcutScope(rootRef.current, form);
|
|
394
|
+
if (!scope || !(e.target instanceof Node) || !scope.contains(e.target)) return;
|
|
395
|
+
e.preventDefault();
|
|
396
|
+
saveRef.current?.click();
|
|
397
|
+
};
|
|
398
|
+
document.addEventListener("keydown", onKeyDown);
|
|
399
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
400
|
+
}, [submitShortcut, form]);
|
|
401
|
+
const apple = submitShortcut ? isApplePlatform() : false;
|
|
281
402
|
const placement = useResolvedPlacement(placementProp);
|
|
282
403
|
const bleedLength = bleed === undefined || bleed === "" ? undefined : cssLength(bleed);
|
|
283
404
|
const hasDestructive = destructive !== undefined && destructive !== null && destructive !== false;
|
|
@@ -288,6 +409,7 @@ export function FormActions({
|
|
|
288
409
|
type="button"
|
|
289
410
|
variant="ghost"
|
|
290
411
|
tone="danger"
|
|
412
|
+
size={size}
|
|
291
413
|
onClick={destructive.onClick}
|
|
292
414
|
disabled={destructive.disabled}
|
|
293
415
|
disabledReason={destructive.disabledReason}
|
|
@@ -299,9 +421,13 @@ export function FormActions({
|
|
|
299
421
|
destructive
|
|
300
422
|
);
|
|
301
423
|
const saveLabel = submitLabel ?? labels.save;
|
|
424
|
+
// The glyphs follow the text: 16px beside `md`'s 14px text, 14px beside `sm`'s 12px.
|
|
425
|
+
const saveSize = submitProps?.size ?? size;
|
|
426
|
+
const glyphClass = saveSize === "sm" ? "size-3.5" : "size-4";
|
|
302
427
|
|
|
303
428
|
return (
|
|
304
429
|
<div
|
|
430
|
+
ref={rootRef}
|
|
305
431
|
data-slot="form-actions"
|
|
306
432
|
data-placement={placement}
|
|
307
433
|
data-sticky-within={placement === "sticky" ? stickyWithin : undefined}
|
|
@@ -336,16 +462,21 @@ export function FormActions({
|
|
|
336
462
|
<div className="flex flex-wrap items-center gap-2">
|
|
337
463
|
{children}
|
|
338
464
|
{onCancel && (
|
|
339
|
-
<Button type="button" variant=
|
|
465
|
+
<Button type="button" variant={cancelVariant} size={size} onClick={onCancel}>
|
|
340
466
|
{cancelLabel ?? labels.cancel}
|
|
341
467
|
</Button>
|
|
342
468
|
)}
|
|
343
469
|
<Button
|
|
344
470
|
{...submitProps}
|
|
471
|
+
ref={setSaveRef}
|
|
472
|
+
aria-keyshortcuts={
|
|
473
|
+
submitShortcut === "mod-enter" ? "Control+Enter Meta+Enter" : submitProps?.["aria-keyshortcuts"]
|
|
474
|
+
}
|
|
345
475
|
type={onSubmit ? "button" : "submit"}
|
|
346
476
|
form={form}
|
|
347
477
|
onClick={onSubmit}
|
|
348
478
|
variant={submitVariant}
|
|
479
|
+
size={saveSize}
|
|
349
480
|
disabled={pending || submitDisabled}
|
|
350
481
|
disabledReason={pending ? undefined : submitDisabledReason}
|
|
351
482
|
// Not while pending: the save already left, and the lock's look over the
|
|
@@ -354,11 +485,21 @@ export function FormActions({
|
|
|
354
485
|
aria-busy={pending || undefined}
|
|
355
486
|
>
|
|
356
487
|
{pending ? (
|
|
357
|
-
<Spinner label={null} className=
|
|
488
|
+
<Spinner label={null} className={glyphClass} />
|
|
358
489
|
) : (
|
|
359
|
-
SubmitIcon && <SubmitIcon aria-hidden className="
|
|
490
|
+
SubmitIcon && <SubmitIcon aria-hidden className={cn(glyphClass, "shrink-0")} />
|
|
360
491
|
)}
|
|
361
492
|
{pending && pendingLabel !== undefined ? pendingLabel : saveLabel}
|
|
493
|
+
{submitShortcut && submitShortcutHint && (
|
|
494
|
+
// Hidden from the accessibility tree: `aria-keyshortcuts` already says it,
|
|
495
|
+
// and in the name it would make the button "Save Ctrl+Enter".
|
|
496
|
+
<kbd
|
|
497
|
+
aria-hidden
|
|
498
|
+
className="ms-1 font-sans text-[0.85em] font-normal whitespace-nowrap opacity-70 pointer-coarse:hidden"
|
|
499
|
+
>
|
|
500
|
+
{labels.submitShortcut(apple)}
|
|
501
|
+
</kbd>
|
|
502
|
+
)}
|
|
362
503
|
</Button>
|
|
363
504
|
</div>
|
|
364
505
|
</div>
|