@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
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
2
|
-
import type { ChangeEvent, ComponentPropsWithoutRef, FormEvent, ReactNode
|
|
1
|
+
import { forwardRef, useCallback, useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
import type { ChangeEvent, ComponentPropsWithoutRef, FormEvent, ReactNode } from "react";
|
|
3
3
|
import { cn } from "../lib/cn";
|
|
4
4
|
import { useKitLabels } from "../i18n/kit-labels";
|
|
5
5
|
import { Button, Input, Label, Spinner } from "./ui";
|
|
6
|
+
import type { InputProps } from "./ui";
|
|
7
|
+
import { Checkbox } from "./checkbox";
|
|
6
8
|
import { Tooltip } from "./tooltip";
|
|
9
|
+
import { useCommitReason } from "./write-lock";
|
|
7
10
|
|
|
8
11
|
/**
|
|
9
12
|
* How typed text is compared with the text it has to repeat — shared by
|
|
@@ -67,47 +70,229 @@ export function usePromisePending() {
|
|
|
67
70
|
return { pending, run };
|
|
68
71
|
}
|
|
69
72
|
|
|
73
|
+
/** A label that is really there — `null`, `false` and `""` are what a caller's
|
|
74
|
+
* `cond && reason` evaluates to on the path where there is none. */
|
|
75
|
+
function hasContent(node: ReactNode): boolean {
|
|
76
|
+
return node !== undefined && node !== null && node !== false && node !== "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The one word {@link CurrentPasswordInput} says on its own behalf — the
|
|
80
|
+
* `dangerConfirm.password` label, shared with DangerConfirm's password field, so an
|
|
81
|
+
* app translates "Password" once. */
|
|
82
|
+
export type CurrentPasswordInputLabels = Pick<DangerConfirmLabels, "password">;
|
|
83
|
+
|
|
70
84
|
/**
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
|
|
74
|
-
|
|
85
|
+
* Everything {@link Input} takes except what makes this field what it is: `type` is
|
|
86
|
+
* always `"password"` and `autoComplete` always `"current-password"`.
|
|
87
|
+
*/
|
|
88
|
+
export interface CurrentPasswordInputProps extends Omit<InputProps, "type" | "autoComplete" | "label"> {
|
|
89
|
+
/**
|
|
90
|
+
* The visible label. Default `dangerConfirm.password` ("Password") — unless the field
|
|
91
|
+
* is named another way (`aria-label` / `aria-labelledby`), which then stays the only
|
|
92
|
+
* name. Pass the app's own word on a sign-in form ("Password", "Your password"), or
|
|
93
|
+
* `null` for a field whose `<label htmlFor>` is drawn elsewhere (a form library's
|
|
94
|
+
* `FormLabel`), which would otherwise be labelled twice.
|
|
95
|
+
*/
|
|
96
|
+
label?: ReactNode;
|
|
97
|
+
/** The value as a string — the `(next) => …` shape, so a caller holding it in
|
|
98
|
+
* `useState` need not unwrap `event.target.value`. Fires alongside `onChange`,
|
|
99
|
+
* never instead of it, so a form library's `onChange` keeps working. */
|
|
100
|
+
onValueChange?: (value: string) => void;
|
|
101
|
+
/**
|
|
102
|
+
* A check is running (the sign-in request, the re-auth). The field turns `readOnly`,
|
|
103
|
+
* NOT `disabled`: the field being submitted is usually the focused one (Enter was
|
|
104
|
+
* pressed in it), and disabling a focused field drops focus to `<body>` — the user
|
|
105
|
+
* lands nowhere when the "wrong password" answer comes back.
|
|
106
|
+
*/
|
|
107
|
+
busy?: boolean;
|
|
108
|
+
/** Per-instance override of `dangerConfirm.password`. */
|
|
109
|
+
labels?: Partial<CurrentPasswordInputLabels>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The "prove it is you" field: a password input that a password manager fills with
|
|
114
|
+
* the CURRENT password and never offers to generate a new one for.
|
|
115
|
+
*
|
|
116
|
+
* It is the field of a sign-in form, a re-auth step ({@link ReauthDialog} draws the same
|
|
117
|
+
* field), and {@link DangerConfirm}'s `requirePassword`. Public since 0.22 for kastlan's
|
|
118
|
+
* sign-in and re-auth (kastlan 1), which wrote `type="password"` +
|
|
119
|
+
* `autoComplete="current-password"` by hand — and a sign-in field that forgets the
|
|
120
|
+
* second half gets the browser's "suggest a strong password" offer on the one form where
|
|
121
|
+
* a NEW password is never what is wanted. The reverse matters as much: a field that sets
|
|
122
|
+
* a NEW password (a passphrase, a sign-up) must NOT be this one — keksdose #170 keeps
|
|
123
|
+
* its passphrase doors on `new-password` / `off` for that reason.
|
|
124
|
+
*
|
|
125
|
+
* Everything else is {@link Input}: the reveal toggle, `error` (under the field,
|
|
126
|
+
* `aria-describedby`, `aria-invalid`), `invalid`, `name`, `id`, `autoFocus`, `required`.
|
|
127
|
+
* The ref is the `<input>`, so it works with `@eifi1/ui-kit/rhf`'s `FormControl` /
|
|
128
|
+
* a `register()` spread, and controlled or uncontrolled.
|
|
75
129
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
130
|
+
* ```tsx
|
|
131
|
+
* <CurrentPasswordInput name="password" label={t("password")} error={errors.password?.message}
|
|
132
|
+
* busy={isSubmitting} {...register("password")} />
|
|
133
|
+
* ```
|
|
78
134
|
*/
|
|
79
|
-
export
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
135
|
+
export const CurrentPasswordInput = forwardRef<HTMLInputElement, CurrentPasswordInputProps>(
|
|
136
|
+
function CurrentPasswordInput({ label, onValueChange, onChange, busy, readOnly, labels: labelsProp, ...rest }, ref) {
|
|
137
|
+
const labels = useKitLabels("dangerConfirm", DEFAULT_DANGER_CONFIRM_LABELS, labelsProp);
|
|
138
|
+
const namedOtherwise = rest["aria-label"] !== undefined || rest["aria-labelledby"] !== undefined;
|
|
139
|
+
return (
|
|
140
|
+
<Input
|
|
141
|
+
{...rest}
|
|
142
|
+
ref={ref}
|
|
143
|
+
// After the spread: a props object spread at the field must not turn it into a
|
|
144
|
+
// text field, or into one the password manager offers a new password for.
|
|
145
|
+
type="password"
|
|
146
|
+
autoComplete="current-password"
|
|
147
|
+
label={label === undefined ? (namedOtherwise ? undefined : labels.password) : hasContent(label) ? label : undefined}
|
|
148
|
+
readOnly={Boolean(busy) || readOnly}
|
|
149
|
+
onChange={(e) => {
|
|
150
|
+
onChange?.(e);
|
|
151
|
+
onValueChange?.(e.target.value);
|
|
152
|
+
}}
|
|
153
|
+
/>
|
|
154
|
+
);
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
CurrentPasswordInput.displayName = "CurrentPasswordInput";
|
|
158
|
+
|
|
159
|
+
/** The words {@link TypedConfirmField} says on its own behalf — the `dangerConfirm`
|
|
160
|
+
* namespace's `phrase` and `phrasePlaceholder`, shared with DangerConfirm's own field,
|
|
161
|
+
* so the sentence "Type “X” to confirm" is translated once. */
|
|
162
|
+
export type TypedConfirmFieldLabels = Pick<DangerConfirmLabels, "phrase" | "phrasePlaceholder">;
|
|
163
|
+
|
|
164
|
+
export interface TypedConfirmFieldProps
|
|
165
|
+
extends Omit<InputProps, "type" | "value" | "defaultValue" | "label" | "placeholder"> {
|
|
166
|
+
/** The text to repeat — a word ("DELETE"), a name, an e-mail address. */
|
|
167
|
+
target: string;
|
|
168
|
+
/** How the typed text is compared with `target` — {@link TypedMatch}. Default
|
|
169
|
+
* `"trim"`, as {@link typedMatches}; `"caseless"` for an address or an
|
|
170
|
+
* acknowledgement word a user may type in any case. */
|
|
171
|
+
match?: TypedMatch;
|
|
172
|
+
/** Controlled value. Left out, the field keeps its own (start: `defaultValue`). */
|
|
173
|
+
value?: string;
|
|
174
|
+
defaultValue?: string;
|
|
175
|
+
/** The typed text as a string, on every keystroke. Fires alongside `onChange`. */
|
|
176
|
+
onValueChange?: (value: string) => void;
|
|
177
|
+
/**
|
|
178
|
+
* The typed text started or stopped matching `target` — fired from the keystroke
|
|
179
|
+
* that flipped it, so an uncontrolled field can still drive the button it guards. A
|
|
180
|
+
* controlled caller that resets the value itself (on a close) knows the answer
|
|
181
|
+
* already, and gets no call for that; it can also compute it with {@link typedMatches}.
|
|
182
|
+
*/
|
|
183
|
+
onMatchedChange?: (matched: boolean) => void;
|
|
184
|
+
/** The label. Default `dangerConfirm.phrase(target)` — "Type “DELETE” to confirm". */
|
|
185
|
+
label?: ReactNode;
|
|
186
|
+
/** Text inside the empty field. Default `dangerConfirm.phrasePlaceholder` (unset).
|
|
187
|
+
* Given, the label moves ABOVE the field: a floating label occupies the empty
|
|
188
|
+
* field, which is exactly where a placeholder shows. `""` means none. */
|
|
189
|
+
placeholder?: string;
|
|
190
|
+
/** The guarded action is running: the field turns `readOnly` (not `disabled`, which
|
|
191
|
+
* would drop the focus Enter left in it). */
|
|
192
|
+
busy?: boolean;
|
|
193
|
+
labels?: Partial<TypedConfirmFieldLabels>;
|
|
109
194
|
}
|
|
110
195
|
|
|
196
|
+
/**
|
|
197
|
+
* The "type this to confirm" field — {@link DangerConfirm}'s `phrase` field and
|
|
198
|
+
* `useConfirm({ requireTyped })`'s, on its own (keksdose K7).
|
|
199
|
+
*
|
|
200
|
+
* keksdose writes it by hand four times: the privacy enrol, upgrade and sweep dialogs
|
|
201
|
+
* (type the acknowledgement word, then the key doors appear) and the admin's "type the
|
|
202
|
+
* user's address" (`UserActionConfirm`), each an `Input` plus its own
|
|
203
|
+
* `trim().toUpperCase()` / `toLowerCase()` comparison. None of those places is an
|
|
204
|
+
* arm → confirm tile or a modal confirm — what is guarded is a whole form, or the
|
|
205
|
+
* next step of a dialog — so the field had to stand alone. What it brings over a plain
|
|
206
|
+
* `Input`: one match rule ({@link typedMatches}, which lower-cases locale-independently
|
|
207
|
+
* on purpose), the label worded from the target in the app's language, and the
|
|
208
|
+
* attributes a phone keyboard needs to leave the text alone (no autocomplete, no
|
|
209
|
+
* auto-capital, no autocorrect, no spellcheck — a keyboard that "corrects" an e-mail
|
|
210
|
+
* address makes the guard unpassable).
|
|
211
|
+
*
|
|
212
|
+
* The field reports whether it matches (`onMatchedChange`, and `data-matched` on the
|
|
213
|
+
* `<input>` for styling); what the match unlocks is the caller's. The ref is the
|
|
214
|
+
* `<input>`.
|
|
215
|
+
*
|
|
216
|
+
* ```tsx
|
|
217
|
+
* const [ok, setOk] = useState(false);
|
|
218
|
+
* <TypedConfirmField target={t("privacy.enroll_ack_word")} match="caseless" onMatchedChange={setOk} />
|
|
219
|
+
* {ok && <KeyDoorsForm … />}
|
|
220
|
+
* ```
|
|
221
|
+
*/
|
|
222
|
+
export const TypedConfirmField = forwardRef<HTMLInputElement, TypedConfirmFieldProps>(function TypedConfirmField(
|
|
223
|
+
{
|
|
224
|
+
target,
|
|
225
|
+
match = "trim",
|
|
226
|
+
value: valueProp,
|
|
227
|
+
defaultValue = "",
|
|
228
|
+
onValueChange,
|
|
229
|
+
onMatchedChange,
|
|
230
|
+
onChange,
|
|
231
|
+
label: labelProp,
|
|
232
|
+
placeholder: placeholderProp,
|
|
233
|
+
busy,
|
|
234
|
+
readOnly,
|
|
235
|
+
labels: labelsProp,
|
|
236
|
+
id,
|
|
237
|
+
className,
|
|
238
|
+
...rest
|
|
239
|
+
},
|
|
240
|
+
ref,
|
|
241
|
+
) {
|
|
242
|
+
const labels = useKitLabels("dangerConfirm", DEFAULT_DANGER_CONFIRM_LABELS, labelsProp);
|
|
243
|
+
const [own, setOwn] = useState(defaultValue);
|
|
244
|
+
const value = valueProp ?? own;
|
|
245
|
+
const matched = typedMatches(value, target, match);
|
|
246
|
+
const generated = useId();
|
|
247
|
+
const fieldId = id ?? generated;
|
|
248
|
+
const label =
|
|
249
|
+
labelProp !== undefined
|
|
250
|
+
? labelProp
|
|
251
|
+
: typeof labels.phrase === "function"
|
|
252
|
+
? labels.phrase(target)
|
|
253
|
+
: labels.phrase;
|
|
254
|
+
const fromLabels =
|
|
255
|
+
labels.phrasePlaceholder === undefined
|
|
256
|
+
? undefined
|
|
257
|
+
: typeof labels.phrasePlaceholder === "function"
|
|
258
|
+
? labels.phrasePlaceholder(target)
|
|
259
|
+
: labels.phrasePlaceholder;
|
|
260
|
+
const placeholder = placeholderProp ?? fromLabels;
|
|
261
|
+
|
|
262
|
+
const fieldProps = {
|
|
263
|
+
...rest,
|
|
264
|
+
ref,
|
|
265
|
+
id: fieldId,
|
|
266
|
+
value,
|
|
267
|
+
autoComplete: "off",
|
|
268
|
+
autoCapitalize: "off",
|
|
269
|
+
autoCorrect: "off",
|
|
270
|
+
spellCheck: false,
|
|
271
|
+
readOnly: Boolean(busy) || readOnly,
|
|
272
|
+
"data-matched": matched || undefined,
|
|
273
|
+
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
|
274
|
+
onChange?.(e);
|
|
275
|
+
const next = e.target.value;
|
|
276
|
+
if (valueProp === undefined) setOwn(next);
|
|
277
|
+
onValueChange?.(next);
|
|
278
|
+
const nextMatched = typedMatches(next, target, match);
|
|
279
|
+
if (nextMatched !== matched) onMatchedChange?.(nextMatched);
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
if (placeholder) {
|
|
284
|
+
return (
|
|
285
|
+
// A static label above, so the placeholder has the empty field to itself.
|
|
286
|
+
<div className={cn("space-y-1.5", className)}>
|
|
287
|
+
<Label htmlFor={fieldId}>{label}</Label>
|
|
288
|
+
<Input {...fieldProps} placeholder={placeholder} />
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
return <Input {...fieldProps} label={label} className={className} />;
|
|
293
|
+
});
|
|
294
|
+
TypedConfirmField.displayName = "TypedConfirmField";
|
|
295
|
+
|
|
111
296
|
/**
|
|
112
297
|
* Every string the tile renders — the `dangerConfirm` namespace of
|
|
113
298
|
* `<UiKitProvider labels>`, overridable per instance through `labels`. The arm and
|
|
@@ -136,6 +321,21 @@ export interface DangerConfirmLabels {
|
|
|
136
321
|
* label occupies the empty field, which is exactly where a placeholder shows) and
|
|
137
322
|
* the placeholder fills the field. An empty string means none. */
|
|
138
323
|
phrasePlaceholder?: string | ((phrase: string) => string);
|
|
324
|
+
/** 0.22: the checkbox `requireAcknowledge={true}` shows — "I have read what this does
|
|
325
|
+
* and want to continue" (keksdose's `admin.users.confirm_ack`). */
|
|
326
|
+
acknowledge: string;
|
|
327
|
+
/**
|
|
328
|
+
* 0.23: why the armed confirm is held while the typed phrase does not match yet — in
|
|
329
|
+
* the confirm's tooltip and its description (keksdose G4a). A FUNCTION of the phrase
|
|
330
|
+
* by default ("Type “DELETE” to confirm"), or a finished STRING, as `phrase`.
|
|
331
|
+
*/
|
|
332
|
+
needsPhrase: string | ((phrase: string) => string);
|
|
333
|
+
/** 0.23: why the armed confirm is held while the `requireAcknowledge` box is unticked
|
|
334
|
+
* — "Tick the box to confirm". */
|
|
335
|
+
needsAcknowledge: string;
|
|
336
|
+
/** 0.23: why the armed confirm is held while the `requirePassword` field is empty —
|
|
337
|
+
* "Enter your password to confirm". */
|
|
338
|
+
needsPassword: string;
|
|
139
339
|
}
|
|
140
340
|
|
|
141
341
|
/** `satisfies` rather than a type annotation, so `DEFAULT_DANGER_CONFIRM_LABELS.phrase`
|
|
@@ -147,8 +347,48 @@ export const DEFAULT_DANGER_CONFIRM_LABELS = {
|
|
|
147
347
|
prompt: "This cannot be undone.",
|
|
148
348
|
password: "Password",
|
|
149
349
|
phrase: (phrase: string) => `Type “${phrase}” to confirm`,
|
|
350
|
+
acknowledge: "I have read what this does and want to continue.",
|
|
351
|
+
needsPhrase: (phrase: string) => `Type “${phrase}” to confirm`,
|
|
352
|
+
needsAcknowledge: "Tick the box to confirm",
|
|
353
|
+
needsPassword: "Enter your password to confirm",
|
|
150
354
|
} satisfies DangerConfirmLabels;
|
|
151
355
|
|
|
356
|
+
/**
|
|
357
|
+
* What the guards were answered with — the second argument of
|
|
358
|
+
* {@link DangerConfirmProps.onConfirm} (0.23, keksdose G4b). A key is there only when its
|
|
359
|
+
* guard was asked for, so the object can go into a request body as it is.
|
|
360
|
+
*/
|
|
361
|
+
export interface DangerConfirmValues {
|
|
362
|
+
/**
|
|
363
|
+
* The text typed into the `phrase` field — as the match rule compared it: trimmed
|
|
364
|
+
* under `phraseMatch` `"trim"` and `"caseless"` (the spaces a phone keyboard adds are
|
|
365
|
+
* not part of it), exactly as typed under `"exact"`. The case is the user's, even
|
|
366
|
+
* under `"caseless"`: a server that re-checks applies its own rule to it. Absent
|
|
367
|
+
* without a `phrase`.
|
|
368
|
+
*/
|
|
369
|
+
typed?: string;
|
|
370
|
+
/** The entered password — the same value as the first argument. Absent without
|
|
371
|
+
* `requirePassword`. */
|
|
372
|
+
password?: string;
|
|
373
|
+
/** `true` when the `requireAcknowledge` box was shown — it was ticked, since the
|
|
374
|
+
* confirm is held until it is. Absent without one. */
|
|
375
|
+
acknowledged?: true;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* One line of {@link DangerConfirmProps.consequences}: what the action will do. A plain
|
|
380
|
+
* string, or this, for a key of its own and the `severe` mark.
|
|
381
|
+
*/
|
|
382
|
+
export interface DangerConsequence {
|
|
383
|
+
/** React key — keksdose keys its lines by what they say ("api_tokens", "only_door").
|
|
384
|
+
* Default: the text when it is a string, else the position. */
|
|
385
|
+
key?: string;
|
|
386
|
+
text: ReactNode;
|
|
387
|
+
/** "There is no way back" — painted in the danger colour rather than the list's
|
|
388
|
+
* muted one. keksdose reserves it for the one line that loses something for good. */
|
|
389
|
+
severe?: boolean;
|
|
390
|
+
}
|
|
391
|
+
|
|
152
392
|
export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">, "onChange"> {
|
|
153
393
|
/**
|
|
154
394
|
* Runs the action, with the entered password when `requirePassword` is on.
|
|
@@ -157,12 +397,47 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
|
|
|
157
397
|
* (fields wiped) when it resolves, still armed when it rejects — so the user can
|
|
158
398
|
* correct a wrong password and retry. The rejection is not swallowed for you to
|
|
159
399
|
* miss: handle it (and show why) in the caller, as with any mutation.
|
|
400
|
+
*
|
|
401
|
+
* 0.23: the second argument is what every guard was answered with — the typed phrase,
|
|
402
|
+
* the password, the tick ({@link DangerConfirmValues}). keksdose G4b: its admin
|
|
403
|
+
* actions send the address the admin TYPED, and the server re-checks it against the
|
|
404
|
+
* account (`assert_confirmed`), so a client that put the wrong row's id in the request
|
|
405
|
+
* is refused rather than obeyed; the tile kept the text to itself.
|
|
406
|
+
*
|
|
407
|
+
* onConfirm={(_password, { typed }) =>
|
|
408
|
+
* reset.mutateAsync({ id, confirm: { acknowledged: true, confirm_email: typed ?? null } })}
|
|
409
|
+
*
|
|
410
|
+
* A second argument rather than one object in place of the password: every
|
|
411
|
+
* `(password) => …` and `() => …` handler, and a `mutation.mutate` passed as it is,
|
|
412
|
+
* keeps working unchanged, and a handler that needs one guard's answer names it.
|
|
160
413
|
*/
|
|
161
|
-
onConfirm: (password
|
|
162
|
-
/** Show a password field; confirm
|
|
414
|
+
onConfirm: (password: string | undefined, values: DangerConfirmValues) => void | Promise<unknown>;
|
|
415
|
+
/** Show a password field; confirm is held until it is filled. */
|
|
163
416
|
requirePassword?: boolean;
|
|
164
|
-
/**
|
|
165
|
-
*
|
|
417
|
+
/**
|
|
418
|
+
* Show an "I understand" checkbox; confirm is held until it is ticked
|
|
419
|
+
* (keksdose K7). `true` words it with `labels.acknowledge`; any other node IS the
|
|
420
|
+
* checkbox's label ("I understand Anna will be signed out everywhere").
|
|
421
|
+
*
|
|
422
|
+
* The guard for an action whose risk is MIS-TARGETING less than carelessness:
|
|
423
|
+
* keksdose's admin password reset asks for a typed address when the account has an
|
|
424
|
+
* encrypted door to lose, and a tick otherwise (`UserActionConfirm`), and its plan
|
|
425
|
+
* editor asks for the tick alone (`UserPlanEditor`). Both were hand-built beside this
|
|
426
|
+
* tile because it had no tick. It combines with `phrase` and `requirePassword`: every
|
|
427
|
+
* guard given must be satisfied.
|
|
428
|
+
*/
|
|
429
|
+
requireAcknowledge?: ReactNode;
|
|
430
|
+
/**
|
|
431
|
+
* What the action will do, said before it happens — a list under the prompt, shown
|
|
432
|
+
* once armed (keksdose K7, `UserActionConfirm`'s consequences). Strings, or
|
|
433
|
+
* {@link DangerConsequence}s for a `severe` line in the danger colour.
|
|
434
|
+
*
|
|
435
|
+
* Derive them from the row's own facts rather than reciting the worst case: an admin
|
|
436
|
+
* who reads the same paragraph on every row stops reading it (keksdose dev#488).
|
|
437
|
+
*/
|
|
438
|
+
consequences?: readonly (string | DangerConsequence)[];
|
|
439
|
+
/** Show a "type <phrase> to confirm" field; confirm is held until the field matches
|
|
440
|
+
* (case-sensitive; surrounding spaces ignored unless `phraseMatch="exact"`). */
|
|
166
441
|
phrase?: string;
|
|
167
442
|
/**
|
|
168
443
|
* How the typed text is compared with `phrase`. `"trim"` (default) ignores spaces
|
|
@@ -195,9 +470,20 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
|
|
|
195
470
|
* stays focusable (`aria-disabled`) so a keyboard user can land on it and hear why.
|
|
196
471
|
*
|
|
197
472
|
* On the ARM button rather than the confirm: arming asks for a password, and asking
|
|
198
|
-
* for a password for a write that can never land is the worse of the two.
|
|
473
|
+
* for a password for a write that can never land is the worse of the two. A tile that
|
|
474
|
+
* is ALREADY armed when the reason arrives (controlled `armed`, or a lock that landed
|
|
475
|
+
* while it was open) keeps its fields but its confirm takes the reason the same way
|
|
476
|
+
* — focusable, `aria-disabled`, the reason in its tooltip — and Enter does nothing.
|
|
199
477
|
*/
|
|
200
478
|
lockedReason?: ReactNode;
|
|
479
|
+
/**
|
|
480
|
+
* This tile COMMITS — the 0.18 write-lock opt-in that {@link Button} has (keksdose
|
|
481
|
+
* K3). Under a locked {@link WriteLockProvider} the lock's reason becomes
|
|
482
|
+
* `lockedReason` (and wins over one of the tile's own), so a page that mounts the
|
|
483
|
+
* provider no longer threads `lockedReason={lock.locked ? lock.reason : undefined}`
|
|
484
|
+
* into every tile. No provider, or an unlocked one: no effect.
|
|
485
|
+
*/
|
|
486
|
+
commit?: boolean;
|
|
201
487
|
/** Controlled armed state. The parent can then collapse the tile from a mutation's
|
|
202
488
|
* own `onSuccess` without returning a promise. */
|
|
203
489
|
armed?: boolean;
|
|
@@ -209,8 +495,18 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
|
|
|
209
495
|
|
|
210
496
|
/**
|
|
211
497
|
* An "arm → confirm" tile for destructive actions: one button, which expands into a
|
|
212
|
-
* warning, an optional
|
|
213
|
-
* confirm
|
|
498
|
+
* warning, an optional list of consequences, an optional "I understand" tick, an
|
|
499
|
+
* optional type-to-confirm field, an optional password field and a confirm that is held
|
|
500
|
+
* until every guard is satisfied.
|
|
501
|
+
*
|
|
502
|
+
* A held confirm SAYS which guard is still open (0.23, keksdose G4a): it is
|
|
503
|
+
* `aria-disabled` rather than `disabled` — still focusable, so a keyboard user can land
|
|
504
|
+
* on it — with the reason in the kit {@link Tooltip} and its description ("Type
|
|
505
|
+
* “DELETE” to confirm", "Tick the box to confirm", "Enter your password to confirm";
|
|
506
|
+
* `labels.needs*`), the first open guard in the order they are drawn. FormActions'
|
|
507
|
+
* `submitDisabledReason` does the same for a form's Save. Pressing it, or Enter in a
|
|
508
|
+
* field, does nothing. A lock's reason wins over a guard's; while the action runs the
|
|
509
|
+
* confirm is plainly disabled, its spinner saying why.
|
|
214
510
|
*
|
|
215
511
|
* Keksdose hand-rolled it three times (load demo data, wipe everything, reset a
|
|
216
512
|
* budget) and then as `shared/components/danger-confirm.tsx`; the only app-specific
|
|
@@ -225,6 +521,8 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
|
|
|
225
521
|
export function DangerConfirm({
|
|
226
522
|
onConfirm,
|
|
227
523
|
requirePassword,
|
|
524
|
+
requireAcknowledge,
|
|
525
|
+
consequences,
|
|
228
526
|
phrase,
|
|
229
527
|
phraseMatch = "trim",
|
|
230
528
|
prompt,
|
|
@@ -233,7 +531,8 @@ export function DangerConfirm({
|
|
|
233
531
|
confirmLabel,
|
|
234
532
|
busy: busyProp,
|
|
235
533
|
disabled,
|
|
236
|
-
lockedReason,
|
|
534
|
+
lockedReason: ownLockedReason,
|
|
535
|
+
commit,
|
|
237
536
|
armed: armedProp,
|
|
238
537
|
onArmedChange,
|
|
239
538
|
labels: labelsProp,
|
|
@@ -241,17 +540,19 @@ export function DangerConfirm({
|
|
|
241
540
|
...rest
|
|
242
541
|
}: DangerConfirmProps) {
|
|
243
542
|
const labels = useKitLabels("dangerConfirm", DEFAULT_DANGER_CONFIRM_LABELS, labelsProp);
|
|
543
|
+
const lockedReason = useCommitReason(commit, ownLockedReason);
|
|
244
544
|
const [armedState, setArmedState] = useState(false);
|
|
245
545
|
const armed = armedProp ?? armedState;
|
|
246
546
|
const [password, setPassword] = useState("");
|
|
247
547
|
const [typed, setTyped] = useState("");
|
|
548
|
+
const [acknowledged, setAcknowledged] = useState(false);
|
|
248
549
|
const { pending, run } = usePromisePending();
|
|
249
550
|
const busy = Boolean(busyProp) || pending;
|
|
250
551
|
// `readOnly` rather than `disabled` on the fields while busy: disabling the field
|
|
251
552
|
// that has focus (Enter was pressed in it) drops focus to <body>.
|
|
252
553
|
const promptId = useId();
|
|
554
|
+
const consequencesId = useId();
|
|
253
555
|
const reasonId = useId();
|
|
254
|
-
const phraseFieldId = useId();
|
|
255
556
|
|
|
256
557
|
// The kit's Button takes no ref, so the two buttons focus is moved to are found by id.
|
|
257
558
|
const armId = useId();
|
|
@@ -270,6 +571,7 @@ export function DangerConfirm({
|
|
|
270
571
|
if (!armed) {
|
|
271
572
|
setPassword("");
|
|
272
573
|
setTyped("");
|
|
574
|
+
setAcknowledged(false);
|
|
273
575
|
}
|
|
274
576
|
}
|
|
275
577
|
|
|
@@ -296,37 +598,53 @@ export function DangerConfirm({
|
|
|
296
598
|
onArmedChange?.(next);
|
|
297
599
|
};
|
|
298
600
|
|
|
299
|
-
const locked = lockedReason
|
|
601
|
+
const locked = hasContent(lockedReason);
|
|
602
|
+
// `true` words the tick with the namespace; any other node is its label; `false`,
|
|
603
|
+
// `null` and `""` are no tick at all.
|
|
604
|
+
const acknowledgeLabel: ReactNode =
|
|
605
|
+
requireAcknowledge === true ? labels.acknowledge : hasContent(requireAcknowledge) ? requireAcknowledge : null;
|
|
606
|
+
const asksAcknowledge = acknowledgeLabel !== null;
|
|
300
607
|
const passwordOk = !requirePassword || password !== "";
|
|
301
608
|
const phraseOk = phrase === undefined || typedMatches(typed, phrase, phraseMatch);
|
|
302
|
-
const
|
|
609
|
+
const acknowledgeOk = !asksAcknowledge || acknowledged;
|
|
610
|
+
const canConfirm = passwordOk && phraseOk && acknowledgeOk && !busy;
|
|
611
|
+
// The first guard still open, in the order the fields are drawn — the one the user
|
|
612
|
+
// meets next, so the sentence points at it.
|
|
613
|
+
const guardReason: string | undefined = !acknowledgeOk
|
|
614
|
+
? labels.needsAcknowledge
|
|
615
|
+
: !phraseOk
|
|
616
|
+
? typeof labels.needsPhrase === "function"
|
|
617
|
+
? labels.needsPhrase(phrase ?? "")
|
|
618
|
+
: labels.needsPhrase
|
|
619
|
+
: !passwordOk
|
|
620
|
+
? labels.needsPassword
|
|
621
|
+
: undefined;
|
|
622
|
+
// The lock first (no guard can lift it); none while busy, when the guards were met and
|
|
623
|
+
// the spinner is the state.
|
|
624
|
+
const confirmReason: ReactNode = locked ? lockedReason : busy ? undefined : guardReason;
|
|
303
625
|
|
|
304
626
|
const submit = (e: FormEvent) => {
|
|
305
627
|
e.preventDefault();
|
|
306
|
-
|
|
628
|
+
// `locked` too: Enter in a field submits the form without the button's say.
|
|
629
|
+
if (!canConfirm || locked) return;
|
|
630
|
+
const values: DangerConfirmValues = {
|
|
631
|
+
...(phrase !== undefined && { typed: phraseMatch === "exact" ? typed : typed.trim() }),
|
|
632
|
+
...(requirePassword && { password }),
|
|
633
|
+
...(asksAcknowledge && { acknowledged: true as const }),
|
|
634
|
+
};
|
|
307
635
|
// Disarms when it resolves; a rejection leaves it armed, fields kept: the caller
|
|
308
636
|
// shows why, the user retries.
|
|
309
|
-
run(onConfirm(
|
|
637
|
+
run(onConfirm(values.password, values), () => setArmed(false));
|
|
310
638
|
};
|
|
311
639
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
phrase === undefined ? "" : typeof labels.phrase === "function" ? labels.phrase(phrase) : labels.phrase;
|
|
321
|
-
const phraseFieldProps = {
|
|
322
|
-
ref: firstFieldRef,
|
|
323
|
-
value: typed,
|
|
324
|
-
autoComplete: "off",
|
|
325
|
-
autoCapitalize: "off",
|
|
326
|
-
spellCheck: false,
|
|
327
|
-
readOnly: busy,
|
|
328
|
-
onChange: (e: ChangeEvent<HTMLInputElement>) => setTyped(e.target.value),
|
|
329
|
-
};
|
|
640
|
+
// The first field takes focus on arm: the tick, then the phrase, then the password —
|
|
641
|
+
// the order they are drawn in.
|
|
642
|
+
const firstField = asksAcknowledge ? "acknowledge" : phrase !== undefined ? "phrase" : "password";
|
|
643
|
+
const lines = (consequences ?? []).map((item, index) =>
|
|
644
|
+
typeof item === "string"
|
|
645
|
+
? { key: item, text: item as ReactNode, severe: false }
|
|
646
|
+
: { key: item.key ?? String(index), text: item.text, severe: Boolean(item.severe) },
|
|
647
|
+
);
|
|
330
648
|
|
|
331
649
|
const toneText = tone === "warning" ? "text-[var(--warning)]" : "text-[var(--danger)]";
|
|
332
650
|
|
|
@@ -377,7 +695,7 @@ export function DangerConfirm({
|
|
|
377
695
|
<div {...rest} className={cn("space-y-2", className)}>
|
|
378
696
|
<form
|
|
379
697
|
className="space-y-2"
|
|
380
|
-
aria-describedby={promptId}
|
|
698
|
+
aria-describedby={lines.length > 0 ? `${promptId} ${consequencesId}` : promptId}
|
|
381
699
|
aria-busy={busy || undefined}
|
|
382
700
|
onSubmit={submit}
|
|
383
701
|
noValidate
|
|
@@ -385,20 +703,43 @@ export function DangerConfirm({
|
|
|
385
703
|
<p id={promptId} className={cn("text-xs font-medium", toneText)}>
|
|
386
704
|
{prompt ?? labels.prompt}
|
|
387
705
|
</p>
|
|
388
|
-
{
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
706
|
+
{lines.length > 0 && (
|
|
707
|
+
<ul id={consequencesId} className="list-disc space-y-1 ps-5 text-xs text-[var(--text-secondary)]">
|
|
708
|
+
{lines.map((line) => (
|
|
709
|
+
<li key={line.key} className={line.severe ? "text-[var(--danger)]" : undefined}>
|
|
710
|
+
{line.text}
|
|
711
|
+
</li>
|
|
712
|
+
))}
|
|
713
|
+
</ul>
|
|
714
|
+
)}
|
|
715
|
+
{asksAcknowledge && (
|
|
716
|
+
<Checkbox
|
|
717
|
+
ref={firstField === "acknowledge" ? firstFieldRef : undefined}
|
|
718
|
+
label={acknowledgeLabel}
|
|
719
|
+
checked={acknowledged}
|
|
720
|
+
// Not `disabled` while busy, for the same reason the fields are readOnly:
|
|
721
|
+
// the box may hold the focus. A change while busy is simply not taken.
|
|
722
|
+
onCheckedChange={(next) => {
|
|
723
|
+
if (!busy) setAcknowledged(next);
|
|
724
|
+
}}
|
|
725
|
+
/>
|
|
726
|
+
)}
|
|
727
|
+
{phrase !== undefined && (
|
|
728
|
+
<TypedConfirmField
|
|
729
|
+
ref={firstField === "phrase" ? firstFieldRef : undefined}
|
|
730
|
+
target={phrase}
|
|
731
|
+
match={phraseMatch}
|
|
732
|
+
value={typed}
|
|
733
|
+
onValueChange={setTyped}
|
|
734
|
+
busy={busy}
|
|
735
|
+
// Already resolved through this tile's own `labels` prop; handed on so a
|
|
736
|
+
// per-instance phrase wording reaches the field.
|
|
737
|
+
labels={{ phrase: labels.phrase, phrasePlaceholder: labels.phrasePlaceholder }}
|
|
738
|
+
/>
|
|
739
|
+
)}
|
|
398
740
|
{requirePassword && (
|
|
399
741
|
<CurrentPasswordInput
|
|
400
|
-
|
|
401
|
-
inputRef={phrase === undefined ? firstFieldRef : undefined}
|
|
742
|
+
ref={firstField === "password" ? firstFieldRef : undefined}
|
|
402
743
|
label={labels.password}
|
|
403
744
|
value={password}
|
|
404
745
|
busy={busy}
|
|
@@ -413,12 +754,18 @@ export function DangerConfirm({
|
|
|
413
754
|
type="submit"
|
|
414
755
|
variant={tone === "warning" ? "primary" : "danger"}
|
|
415
756
|
disabled={!canConfirm}
|
|
757
|
+
// Held by a guard, or armed and then locked (or rendered armed under a lock):
|
|
758
|
+
// the confirm says why the way Button does — focusable, `aria-disabled`, the
|
|
759
|
+
// reason in its tooltip and description, a press swallowed (the submit with
|
|
760
|
+
// it) — and the fields stay as typed. Only busy is the native `disabled`.
|
|
761
|
+
disabledReason={confirmReason}
|
|
416
762
|
aria-busy={busy || undefined}
|
|
417
763
|
>
|
|
418
764
|
{busy && <Spinner label={null} className="h-4 w-4" />}
|
|
419
765
|
{confirmLabel ?? labels.confirm}
|
|
420
766
|
</Button>
|
|
421
767
|
</div>
|
|
768
|
+
{locked && <p className="text-xs text-[var(--text-muted)]">{lockedReason}</p>}
|
|
422
769
|
</form>
|
|
423
770
|
</div>
|
|
424
771
|
);
|