@eifi1/ui-kit 0.17.0 → 0.18.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 +29 -4
- package/dist/chart.d.ts +10 -1
- package/dist/components/account-chips.d.ts +153 -0
- package/dist/components/account-chips.js +135 -0
- package/dist/components/account-chips.js.map +1 -0
- package/dist/components/account-settings-labels.d.ts +10 -2
- package/dist/components/account-settings-labels.js +3 -0
- package/dist/components/account-settings-labels.js.map +1 -1
- package/dist/components/amount-input.d.ts +10 -1
- package/dist/components/button-group.d.ts +10 -1
- package/dist/components/calculator.d.ts +10 -1
- package/dist/components/chip.d.ts +25 -1
- package/dist/components/chip.js +143 -6
- package/dist/components/chip.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +42 -1
- package/dist/components/confirm-dialog.js +36 -5
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.d.ts +10 -1
- package/dist/components/danger-confirm.d.ts +55 -4
- package/dist/components/danger-confirm.js +67 -30
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-cells.d.ts +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.d.ts +1 -1
- package/dist/components/data-table.js +2 -0
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/facing-pair.d.ts +10 -1
- package/dist/components/file-button.d.ts +10 -1
- package/dist/components/file-dropzone.d.ts +10 -1
- package/dist/components/form-actions.d.ts +10 -1
- package/dist/components/form-actions.js +3 -0
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/money-field.d.ts +10 -1
- package/dist/components/number-field.d.ts +11 -2
- package/dist/components/number-field.js +23 -22
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +10 -1
- package/dist/components/numpad-sheet.d.ts +10 -1
- package/dist/components/passkeys-setting.d.ts +27 -1
- package/dist/components/passkeys-setting.js +31 -5
- package/dist/components/passkeys-setting.js.map +1 -1
- package/dist/components/progress-bar.d.ts +15 -1
- package/dist/components/progress-bar.js +8 -1
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/reauth-dialog.d.ts +90 -0
- package/dist/components/reauth-dialog.js +158 -0
- package/dist/components/reauth-dialog.js.map +1 -0
- package/dist/components/series-chart.d.ts +10 -1
- package/dist/components/server-wake.d.ts +58 -0
- package/dist/components/server-wake.js +58 -0
- package/dist/components/server-wake.js.map +1 -0
- package/dist/components/settings-fields.d.ts +10 -1
- package/dist/components/share-card.d.ts +61 -0
- package/dist/components/share-card.js +350 -0
- package/dist/components/share-card.js.map +1 -0
- package/dist/components/status-dot.d.ts +2 -0
- package/dist/components/text-link.d.ts +10 -1
- package/dist/components/time-input.d.ts +10 -1
- package/dist/components/toast.d.ts +10 -1
- package/dist/components/toast.js +42 -2
- package/dist/components/toast.js.map +1 -1
- package/dist/components/toggle-group.js +5 -2
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/ui.d.ts +10 -1
- package/dist/components/ui.js +43 -28
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/components/user-avatar.d.ts +2 -0
- package/dist/components/write-lock.d.ts +68 -0
- package/dist/components/write-lock.js +33 -0
- package/dist/components/write-lock.js.map +1 -0
- package/dist/{data-table-CpkWUJBY.d.ts → data-table-3mi2R3so.d.ts} +19 -1
- package/dist/data-table.d.ts +1 -1
- package/dist/hooks/use-file-drop.d.ts +10 -1
- package/dist/hooks/use-overlay-history.js +16 -0
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/i18n/defaults.d.ts +10 -1
- package/dist/i18n/defaults.js +11 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +78 -0
- package/dist/i18n/{locales/de.js → german.js} +59 -9
- package/dist/i18n/german.js.map +1 -0
- package/dist/i18n/kit-labels.d.ts +10 -1
- package/dist/i18n/kit-labels.js +5 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH.d.ts +23 -8
- package/dist/i18n/locales/de-CH.js +7 -3
- package/dist/i18n/locales/de-CH.js.map +1 -1
- package/dist/i18n/locales/en.d.ts +10 -1
- package/dist/i18n/locales/es.d.ts +10 -1
- package/dist/i18n/locales/es.js +51 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +10 -1
- package/dist/i18n/locales/fr.js +51 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +10 -1
- package/dist/i18n/locales/hu.js +54 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +10 -1
- package/dist/i18n/locales/it.js +51 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +10 -1
- package/dist/i18n/locales/zh.js +50 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/swiss.d.ts +3 -3
- package/dist/i18n/swiss.js.map +1 -1
- package/dist/index.d.ts +9 -4
- package/dist/index.js +33 -1
- package/dist/index.js.map +1 -1
- package/dist/{kit-labels-C4uB_zmg.d.ts → kit-labels-CPbsjlXr.d.ts} +229 -9
- package/dist/lib/server-wake.d.ts +130 -0
- package/dist/lib/server-wake.js +121 -0
- package/dist/lib/server-wake.js.map +1 -0
- package/dist/rhf/fields.d.ts +10 -1
- package/dist/rhf/form.d.ts +10 -1
- package/dist/rhf.d.ts +10 -1
- package/dist/search/global-search.d.ts +3 -1
- package/dist/search/global-search.js.map +1 -1
- package/dist/shell/app-shell.d.ts +10 -1
- package/dist/shell/top-bar-brand.d.ts +10 -1
- package/dist/shell/topbar-action-menu.d.ts +2 -0
- package/dist/shell.d.ts +10 -1
- package/dist/wizard/stepper-nav.d.ts +10 -1
- package/dist/wizard.d.ts +10 -1
- package/package.json +1 -1
- package/src/components/account-chips.tsx +302 -0
- package/src/components/account-settings-labels.ts +16 -2
- package/src/components/chip.tsx +239 -6
- package/src/components/confirm-dialog.tsx +82 -7
- package/src/components/danger-confirm.tsx +118 -33
- package/src/components/data-table.tsx +20 -0
- package/src/components/form-actions.tsx +22 -3
- package/src/components/number-field.tsx +32 -26
- package/src/components/passkeys-setting.tsx +68 -7
- package/src/components/progress-bar.tsx +21 -1
- package/src/components/reauth-dialog.tsx +273 -0
- package/src/components/server-wake.tsx +124 -0
- package/src/components/share-card.tsx +615 -0
- package/src/components/toast.tsx +72 -2
- package/src/components/toggle-group.tsx +5 -2
- package/src/components/ui.tsx +89 -32
- package/src/components/write-lock.tsx +96 -0
- package/src/hooks/use-overlay-history.ts +51 -0
- package/src/i18n/defaults.ts +10 -0
- package/src/i18n/{locales/de.ts → german.ts} +68 -13
- package/src/i18n/kit-labels.tsx +22 -2
- package/src/i18n/locales/de-CH.ts +17 -8
- package/src/i18n/locales/es.ts +51 -0
- package/src/i18n/locales/fr.ts +52 -0
- package/src/i18n/locales/hu.ts +55 -0
- package/src/i18n/locales/it.ts +51 -0
- package/src/i18n/locales/zh.ts +50 -0
- package/src/i18n/swiss.ts +3 -3
- package/src/index.ts +23 -1
- package/src/lib/server-wake.ts +270 -0
- package/src/search/global-search.tsx +3 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +0 -61
- package/dist/i18n/locales/de-CH-informal.js +0 -8
- package/dist/i18n/locales/de-CH-informal.js.map +0 -1
- package/dist/i18n/locales/de-informal.d.ts +0 -82
- package/dist/i18n/locales/de-informal.js +0 -37
- package/dist/i18n/locales/de-informal.js.map +0 -1
- package/dist/i18n/locales/de.d.ts +0 -66
- package/dist/i18n/locales/de.js.map +0 -1
- package/src/i18n/locales/de-CH-informal.ts +0 -10
- package/src/i18n/locales/de-informal.ts +0 -61
|
@@ -2,8 +2,10 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
|
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
|
|
4
4
|
import { useKitLabels } from "../i18n/kit-labels";
|
|
5
|
+
import { typedMatches } from "./danger-confirm";
|
|
6
|
+
import type { TypedMatch } from "./danger-confirm";
|
|
5
7
|
import { DialogFrame } from "./dialog-frame";
|
|
6
|
-
import { Button } from "./ui";
|
|
8
|
+
import { Button, Input } from "./ui";
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* The `confirmDialog` namespace of `<UiKitProvider labels>`: the two buttons' fallback
|
|
@@ -13,12 +15,25 @@ import { Button } from "./ui";
|
|
|
13
15
|
export interface ConfirmDialogLabels {
|
|
14
16
|
confirm: string;
|
|
15
17
|
cancel: string;
|
|
18
|
+
/**
|
|
19
|
+
* 0.18: label of the field a `requireTyped` confirm shows. A FUNCTION of the text to
|
|
20
|
+
* type by default — where the address sits in the sentence moves with the language —
|
|
21
|
+
* or a finished STRING. A call's own `typedLabel` wins over it.
|
|
22
|
+
*
|
|
23
|
+
* Required, like every key of a namespace: the shipped catalogues
|
|
24
|
+
* (`@eifi1/ui-kit/i18n/<code>`) translate it, and an app's own full catalogue should
|
|
25
|
+
* hear about the new string from the compiler rather than show it in English.
|
|
26
|
+
*/
|
|
27
|
+
typed: string | ((text: string) => string);
|
|
16
28
|
}
|
|
17
29
|
|
|
18
|
-
|
|
30
|
+
/** `satisfies` rather than a type annotation, so `.typed` stays known-callable for a
|
|
31
|
+
* caller composing its own label from it. */
|
|
32
|
+
export const DEFAULT_CONFIRM_DIALOG_LABELS = {
|
|
19
33
|
confirm: "Confirm",
|
|
20
34
|
cancel: "Cancel",
|
|
21
|
-
}
|
|
35
|
+
typed: (text: string) => `Type “${text}” to confirm`,
|
|
36
|
+
} satisfies ConfirmDialogLabels;
|
|
22
37
|
|
|
23
38
|
export type ConfirmTone = "danger" | "warning" | "neutral";
|
|
24
39
|
|
|
@@ -37,6 +52,30 @@ export interface ConfirmOptions {
|
|
|
37
52
|
* the confirm. See {@link ConfirmProvider} for why the two differ.
|
|
38
53
|
*/
|
|
39
54
|
tone?: ConfirmTone;
|
|
55
|
+
/**
|
|
56
|
+
* 0.18: make the user TYPE this before confirm is enabled — the target account's
|
|
57
|
+
* e-mail address, a budget's name. For the action whose risk is not "did you mean
|
|
58
|
+
* it" but "is this the right row": an admin who deactivates somebody has already
|
|
59
|
+
* proven who THEY are (they passed the admin gate); typing the address is what
|
|
60
|
+
* proves they read which account they are on. Both keksdose (`TYPE_EMAIL`) and
|
|
61
|
+
* Kurvenschmiede (`TypedConfirm`) built this beside `useConfirm` because it had no
|
|
62
|
+
* field.
|
|
63
|
+
*
|
|
64
|
+
* The dialog then shows one text field under the body and focuses it (whatever the
|
|
65
|
+
* tone: a reflexive Enter cannot confirm a field that does not match yet). Confirm
|
|
66
|
+
* stays disabled until the text matches; Enter in the field confirms once it does.
|
|
67
|
+
* The answer is still `true` / `false` — the text matched what you passed, so send
|
|
68
|
+
* that to the server if it asks for the address too, not the user's spelling.
|
|
69
|
+
*/
|
|
70
|
+
requireTyped?: string;
|
|
71
|
+
/** Label of that field. Default: `confirmDialog.typed` — `Type “a@b.c” to confirm`. */
|
|
72
|
+
typedLabel?: string;
|
|
73
|
+
/**
|
|
74
|
+
* How the typed text is compared. Default `"caseless"`: surrounding spaces and case
|
|
75
|
+
* ignored, the rule both apps applied to an address. `"trim"` keeps case, `"exact"`
|
|
76
|
+
* keeps spaces too — for a phrase like "DELETE" whose contract is "exactly this".
|
|
77
|
+
*/
|
|
78
|
+
typedMatch?: TypedMatch;
|
|
40
79
|
}
|
|
41
80
|
|
|
42
81
|
export type ConfirmFn = (options: ConfirmOptions) => Promise<boolean>;
|
|
@@ -201,15 +240,26 @@ function ConfirmDialogView({
|
|
|
201
240
|
// Set by the confirm button, read once the panel has finished lowering. Everything
|
|
202
241
|
// else that ends in `onClose` — Escape, the backdrop, Back, Cancel — leaves it false.
|
|
203
242
|
const accepted = useRef(false);
|
|
243
|
+
const typedRef = useRef<HTMLInputElement>(null);
|
|
244
|
+
const [typed, setTyped] = useState("");
|
|
245
|
+
const target = request.requireTyped;
|
|
246
|
+
const matches = target === undefined || typedMatches(typed, target, request.typedMatch ?? "caseless");
|
|
204
247
|
|
|
205
248
|
// In THIS component's effect, which runs after `Modal`'s own (a parent's effects run
|
|
206
249
|
// after its children's). `Modal` engages its focus trap on the panel first — and
|
|
207
250
|
// records the page's focused element as the restore target — and only then does
|
|
208
251
|
// focus move to the button. Focusing it from inside the panel would run first and
|
|
209
|
-
// make the button the restore target.
|
|
252
|
+
// make the button the restore target. A typed confirm starts in its field: typing is
|
|
253
|
+
// the one thing the dialog is waiting for.
|
|
210
254
|
useEffect(() => {
|
|
211
|
-
(tone === "danger" ? cancelRef : confirmRef).current?.focus();
|
|
212
|
-
}, [tone]);
|
|
255
|
+
(target !== undefined ? typedRef : tone === "danger" ? cancelRef : confirmRef).current?.focus();
|
|
256
|
+
}, [tone, target]);
|
|
257
|
+
|
|
258
|
+
const typedLabel =
|
|
259
|
+
target === undefined
|
|
260
|
+
? ""
|
|
261
|
+
: (request.typedLabel ??
|
|
262
|
+
(typeof labels.typed === "string" ? labels.typed : labels.typed(target)));
|
|
213
263
|
|
|
214
264
|
return (
|
|
215
265
|
<DialogFrame
|
|
@@ -228,6 +278,7 @@ function ConfirmDialogView({
|
|
|
228
278
|
ref={confirmRef}
|
|
229
279
|
type="button"
|
|
230
280
|
variant={tone === "danger" ? "danger" : "primary"}
|
|
281
|
+
disabled={!matches}
|
|
231
282
|
onClick={() => {
|
|
232
283
|
accepted.current = true;
|
|
233
284
|
close();
|
|
@@ -237,6 +288,30 @@ function ConfirmDialogView({
|
|
|
237
288
|
</Button>
|
|
238
289
|
</>
|
|
239
290
|
)}
|
|
240
|
-
|
|
291
|
+
>
|
|
292
|
+
{/* The field, when there is one, is the body; a plain confirm keeps having none. */}
|
|
293
|
+
{target !== undefined && (
|
|
294
|
+
<form
|
|
295
|
+
noValidate
|
|
296
|
+
onSubmit={(e) => {
|
|
297
|
+
e.preventDefault();
|
|
298
|
+
// Through the button, so Enter and a click are one path — and a disabled
|
|
299
|
+
// (not yet matching) confirm ignores the click.
|
|
300
|
+
confirmRef.current?.click();
|
|
301
|
+
}}
|
|
302
|
+
>
|
|
303
|
+
<Input
|
|
304
|
+
ref={typedRef}
|
|
305
|
+
label={typedLabel}
|
|
306
|
+
value={typed}
|
|
307
|
+
autoComplete="off"
|
|
308
|
+
autoCapitalize="off"
|
|
309
|
+
autoCorrect="off"
|
|
310
|
+
spellCheck={false}
|
|
311
|
+
onChange={(e) => setTyped(e.target.value)}
|
|
312
|
+
/>
|
|
313
|
+
</form>
|
|
314
|
+
)}
|
|
315
|
+
</DialogFrame>
|
|
241
316
|
);
|
|
242
317
|
}
|
|
@@ -1,10 +1,113 @@
|
|
|
1
|
-
import { useEffect, useId, useRef, useState } from "react";
|
|
2
|
-
import type { ChangeEvent, ComponentPropsWithoutRef, FormEvent, ReactNode } from "react";
|
|
1
|
+
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
import type { ChangeEvent, ComponentPropsWithoutRef, FormEvent, ReactNode, Ref } 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
6
|
import { Tooltip } from "./tooltip";
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* How typed text is compared with the text it has to repeat — shared by
|
|
10
|
+
* `DangerConfirm`'s `phraseMatch` and `useConfirm`'s `typedMatch`.
|
|
11
|
+
*
|
|
12
|
+
* - `"trim"` ignores spaces around the typed text — a phone keyboard's autocomplete
|
|
13
|
+
* likes to add one. Case counts: "delete" is not "DELETE".
|
|
14
|
+
* - `"exact"` compares character for character, spaces included.
|
|
15
|
+
* - `"caseless"` trims AND ignores case: for an e-mail address, whose domain is
|
|
16
|
+
* case-insensitive and whose local part every real mail server treats so. Both apps'
|
|
17
|
+
* "type the account's address" rules (keksdose's `TYPE_EMAIL`, Kurvenschmiede's
|
|
18
|
+
* `TypedConfirm`) compared `trim().toLowerCase()`, and a capital the user's keyboard
|
|
19
|
+
* put at the start must not make the right row look like the wrong one.
|
|
20
|
+
*/
|
|
21
|
+
export type TypedMatch = "trim" | "exact" | "caseless";
|
|
22
|
+
|
|
23
|
+
/** Whether `typed` repeats `target` under `mode` — see {@link TypedMatch}. Exported for
|
|
24
|
+
* a caller that draws its own field (a dialog that also picks a recipient) and wants
|
|
25
|
+
* the same rule as the kit's. */
|
|
26
|
+
export function typedMatches(typed: string, target: string, mode: TypedMatch = "trim"): boolean {
|
|
27
|
+
if (mode === "exact") return typed === target;
|
|
28
|
+
if (mode === "trim") return typed.trim() === target;
|
|
29
|
+
// `toLowerCase`, not `toLocaleLowerCase`: the comparison must not change with the
|
|
30
|
+
// reader's locale (a Turkish dotless i would otherwise fail an ASCII address).
|
|
31
|
+
return typed.trim().toLowerCase() === target.trim().toLowerCase();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Busy-while-a-promise-runs, for an action that MAY return one: `run(result, onResolved)`
|
|
36
|
+
* marks the caller pending until `result` settles, calls `onResolved` if it fulfilled,
|
|
37
|
+
* and only clears the flag if it rejected — the caller shows why and the user retries.
|
|
38
|
+
* Nothing runs after unmount. Shared by `DangerConfirm` and `ReauthDialog`, whose
|
|
39
|
+
* contract is the same: resolve = done, reject = stay put.
|
|
40
|
+
*
|
|
41
|
+
* @internal
|
|
42
|
+
*/
|
|
43
|
+
export function usePromisePending() {
|
|
44
|
+
const [pending, setPending] = useState(false);
|
|
45
|
+
const mounted = useRef(true);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
mounted.current = true;
|
|
48
|
+
return () => {
|
|
49
|
+
mounted.current = false;
|
|
50
|
+
};
|
|
51
|
+
}, []);
|
|
52
|
+
const run = useCallback((result: unknown, onResolved: () => void): boolean => {
|
|
53
|
+
if (!result || typeof (result as Promise<unknown>).then !== "function") return false;
|
|
54
|
+
setPending(true);
|
|
55
|
+
(result as Promise<unknown>).then(
|
|
56
|
+
() => {
|
|
57
|
+
if (!mounted.current) return;
|
|
58
|
+
setPending(false);
|
|
59
|
+
onResolved();
|
|
60
|
+
},
|
|
61
|
+
() => {
|
|
62
|
+
if (mounted.current) setPending(false);
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
return true;
|
|
66
|
+
}, []);
|
|
67
|
+
return { pending, run };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The "prove it is you" field: a password input the browser's password manager fills
|
|
72
|
+
* with the CURRENT password (`autocomplete="current-password"`), never offers to
|
|
73
|
+
* generate a new one for. `readOnly` rather than `disabled` while busy: disabling the
|
|
74
|
+
* field that has focus (Enter was pressed in it) drops focus to <body>.
|
|
75
|
+
*
|
|
76
|
+
* @internal DangerConfirm's field. ReauthDialog spells it out with the field's own
|
|
77
|
+
* `error`, which keeps the `<input>` mounted while its message comes and goes.
|
|
78
|
+
*/
|
|
79
|
+
export function CurrentPasswordInput({
|
|
80
|
+
inputRef,
|
|
81
|
+
label,
|
|
82
|
+
value,
|
|
83
|
+
onValueChange,
|
|
84
|
+
busy,
|
|
85
|
+
invalid,
|
|
86
|
+
describedBy,
|
|
87
|
+
}: {
|
|
88
|
+
inputRef?: Ref<HTMLInputElement>;
|
|
89
|
+
label: string;
|
|
90
|
+
value: string;
|
|
91
|
+
onValueChange: (value: string) => void;
|
|
92
|
+
busy: boolean;
|
|
93
|
+
invalid?: boolean;
|
|
94
|
+
describedBy?: string;
|
|
95
|
+
}) {
|
|
96
|
+
return (
|
|
97
|
+
<Input
|
|
98
|
+
ref={inputRef}
|
|
99
|
+
type="password"
|
|
100
|
+
autoComplete="current-password"
|
|
101
|
+
label={label}
|
|
102
|
+
value={value}
|
|
103
|
+
readOnly={busy}
|
|
104
|
+
invalid={invalid}
|
|
105
|
+
aria-describedby={describedBy}
|
|
106
|
+
onChange={(e) => onValueChange(e.target.value)}
|
|
107
|
+
/>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
8
111
|
/**
|
|
9
112
|
* Every string the tile renders — the `dangerConfirm` namespace of
|
|
10
113
|
* `<UiKitProvider labels>`, overridable per instance through `labels`. The arm and
|
|
@@ -65,9 +168,11 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
|
|
|
65
168
|
* How the typed text is compared with `phrase`. `"trim"` (default) ignores spaces
|
|
66
169
|
* around it — a phone keyboard's autocomplete likes to add one. `"exact"` compares
|
|
67
170
|
* character for character, spaces included, for an app whose contract is "type
|
|
68
|
-
* exactly this".
|
|
171
|
+
* exactly this". `"caseless"` (0.18) also ignores case — for a phrase that is an
|
|
172
|
+
* e-mail address (keksdose's "type the user's address" admin rule). See
|
|
173
|
+
* {@link TypedMatch}.
|
|
69
174
|
*/
|
|
70
|
-
phraseMatch?:
|
|
175
|
+
phraseMatch?: TypedMatch;
|
|
71
176
|
/** The warning above the fields. Defaults to `labels.prompt`. */
|
|
72
177
|
prompt?: ReactNode;
|
|
73
178
|
/** `"danger"` (default) for what cannot be undone; `"warning"` for what can, at a
|
|
@@ -140,7 +245,7 @@ export function DangerConfirm({
|
|
|
140
245
|
const armed = armedProp ?? armedState;
|
|
141
246
|
const [password, setPassword] = useState("");
|
|
142
247
|
const [typed, setTyped] = useState("");
|
|
143
|
-
const
|
|
248
|
+
const { pending, run } = usePromisePending();
|
|
144
249
|
const busy = Boolean(busyProp) || pending;
|
|
145
250
|
// `readOnly` rather than `disabled` on the fields while busy: disabling the field
|
|
146
251
|
// that has focus (Enter was pressed in it) drops focus to <body>.
|
|
@@ -155,13 +260,6 @@ export function DangerConfirm({
|
|
|
155
260
|
// Focus moves only after a transition — never on mount, so a tile that renders armed
|
|
156
261
|
// (controlled) does not take the page's focus merely by existing.
|
|
157
262
|
const moveFocus = useRef(false);
|
|
158
|
-
const mounted = useRef(true);
|
|
159
|
-
useEffect(() => {
|
|
160
|
-
mounted.current = true;
|
|
161
|
-
return () => {
|
|
162
|
-
mounted.current = false;
|
|
163
|
-
};
|
|
164
|
-
}, []);
|
|
165
263
|
|
|
166
264
|
// A disarm from anywhere (cancel, a resolved confirm, the parent) wipes the fields.
|
|
167
265
|
// During render, like NumberField's draft, so no frame shows a collapsed tile that
|
|
@@ -200,26 +298,15 @@ export function DangerConfirm({
|
|
|
200
298
|
|
|
201
299
|
const locked = lockedReason !== undefined && lockedReason !== null && lockedReason !== false && lockedReason !== "";
|
|
202
300
|
const passwordOk = !requirePassword || password !== "";
|
|
203
|
-
const phraseOk = phrase === undefined || (
|
|
301
|
+
const phraseOk = phrase === undefined || typedMatches(typed, phrase, phraseMatch);
|
|
204
302
|
const canConfirm = passwordOk && phraseOk && !busy;
|
|
205
303
|
|
|
206
304
|
const submit = (e: FormEvent) => {
|
|
207
305
|
e.preventDefault();
|
|
208
306
|
if (!canConfirm) return;
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
(result as Promise<unknown>).then(
|
|
213
|
-
() => {
|
|
214
|
-
if (!mounted.current) return;
|
|
215
|
-
setPending(false);
|
|
216
|
-
setArmed(false);
|
|
217
|
-
},
|
|
218
|
-
() => {
|
|
219
|
-
// Stays armed, fields kept: the caller shows why, the user retries.
|
|
220
|
-
if (mounted.current) setPending(false);
|
|
221
|
-
},
|
|
222
|
-
);
|
|
307
|
+
// Disarms when it resolves; a rejection leaves it armed, fields kept: the caller
|
|
308
|
+
// shows why, the user retries.
|
|
309
|
+
run(onConfirm(requirePassword ? password : undefined), () => setArmed(false));
|
|
223
310
|
};
|
|
224
311
|
|
|
225
312
|
const phrasePlaceholder =
|
|
@@ -309,15 +396,13 @@ export function DangerConfirm({
|
|
|
309
396
|
<Input label={phraseLabel} {...phraseFieldProps} />
|
|
310
397
|
))}
|
|
311
398
|
{requirePassword && (
|
|
312
|
-
<
|
|
399
|
+
<CurrentPasswordInput
|
|
313
400
|
// The phrase field takes the ref when both are there — it comes first.
|
|
314
|
-
|
|
315
|
-
type="password"
|
|
316
|
-
autoComplete="current-password"
|
|
401
|
+
inputRef={phrase === undefined ? firstFieldRef : undefined}
|
|
317
402
|
label={labels.password}
|
|
318
403
|
value={password}
|
|
319
|
-
|
|
320
|
-
|
|
404
|
+
busy={busy}
|
|
405
|
+
onValueChange={setPassword}
|
|
321
406
|
/>
|
|
322
407
|
)}
|
|
323
408
|
<div className="flex flex-wrap gap-2">
|
|
@@ -355,6 +355,24 @@ export interface DataTableProps<T> {
|
|
|
355
355
|
* of unfolding it inline (feedback #204). Desktop always uses inline expansion.
|
|
356
356
|
*/
|
|
357
357
|
mobileExpandAsDialog?: boolean;
|
|
358
|
+
/**
|
|
359
|
+
* The phone row dialog's `backCloses` ({@link FullBleedDialog}): whether it pushes
|
|
360
|
+
* its own history entry so Back dismisses it. On by default — right for a row whose
|
|
361
|
+
* expansion is component state, where nothing else would answer Back.
|
|
362
|
+
*
|
|
363
|
+
* **Set it `false` when the open row is in the URL** and opening it PUSHES (a
|
|
364
|
+
* `?row=` written with `useSearchParamState(…, { history: "replace-on-clear" })` or
|
|
365
|
+
* `"push"`): Back already closes the row through the router, and a second entry on
|
|
366
|
+
* top of the page's is two owners of one Back press (keksdose dev #584). Keep it on
|
|
367
|
+
* when opening the row REPLACES the URL — then nothing was pushed, and the dialog's
|
|
368
|
+
* own entry is the only thing Back can pop.
|
|
369
|
+
*
|
|
370
|
+
* The table cannot decide this for you: `isExpanded` is a predicate, and neither it
|
|
371
|
+
* nor `rowHref` says whether opening pushed or replaced (keksdose's feedback page
|
|
372
|
+
* has `rowHref` with a REPLACING `?row=`, and needs the entry). Ignored unless
|
|
373
|
+
* `mobileExpandAsDialog`.
|
|
374
|
+
*/
|
|
375
|
+
mobileDialogBackCloses?: boolean;
|
|
358
376
|
/**
|
|
359
377
|
* Group the mobile card list into sections, each with a sticky header — the
|
|
360
378
|
* "assistance"-style list (like the transactions list grouped by date, but by
|
|
@@ -835,6 +853,7 @@ export function DataTable<T>({
|
|
|
835
853
|
locale: localeProp,
|
|
836
854
|
storageKeyPrefix = DEFAULT_PERSIST_PREFIX,
|
|
837
855
|
mobileExpandAsDialog = false,
|
|
856
|
+
mobileDialogBackCloses = true,
|
|
838
857
|
mobileGroupBy,
|
|
839
858
|
mobileGroupLabel,
|
|
840
859
|
mobileCard,
|
|
@@ -1856,6 +1875,7 @@ export function DataTable<T>({
|
|
|
1856
1875
|
onClose={closeDialog}
|
|
1857
1876
|
closeLabel={labels.close}
|
|
1858
1877
|
header={mobileDialogRow ? mobilePrimaryCol?.cell(mobileDialogRow) : null}
|
|
1878
|
+
backCloses={mobileDialogBackCloses}
|
|
1859
1879
|
>
|
|
1860
1880
|
{mobileDialogContent}
|
|
1861
1881
|
</FullBleedDialog>
|
|
@@ -37,7 +37,8 @@ export type FormActionsPlacement = "inline" | "sticky" | "dialog";
|
|
|
37
37
|
* md 768, lg 1024, xl 1280px), as `BulkActionBar`'s `variant` breakpoints are, and
|
|
38
38
|
* resolved the same way, in JS: the sticky row is positioned by inline style, which a
|
|
39
39
|
* `md:` class cannot reach. keksdose switched `placement` on its own media query at
|
|
40
|
-
* every long form for want of this.
|
|
40
|
+
* every long form for want of this. The breakpoints are the VIEWPORT's, not the
|
|
41
|
+
* container's: a form in a narrow pane on a wide screen resolves to `md`.
|
|
41
42
|
*/
|
|
42
43
|
export interface ResponsiveFormActionsPlacement {
|
|
43
44
|
base: FormActionsPlacement;
|
|
@@ -96,7 +97,7 @@ export type FormActionsStickyWithin = "viewport" | "container";
|
|
|
96
97
|
*/
|
|
97
98
|
export type FormActionsSubmitProps = Omit<
|
|
98
99
|
ButtonProps,
|
|
99
|
-
"type" | "onClick" | "disabled" | "disabledReason" | "variant" | "form" | "children" | "aria-busy"
|
|
100
|
+
"type" | "onClick" | "disabled" | "disabledReason" | "variant" | "form" | "children" | "aria-busy" | "commit"
|
|
100
101
|
> & { [key: `data-${string}`]: string | number | boolean | undefined };
|
|
101
102
|
|
|
102
103
|
/** The start-side action of {@link FormActionsProps.destructive}, as data. */
|
|
@@ -157,6 +158,18 @@ export interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">,
|
|
|
157
158
|
submitDisabled?: boolean;
|
|
158
159
|
/** Why save is disabled — see Button's `disabledReason`. Keeps it focusable. */
|
|
159
160
|
submitDisabledReason?: ReactNode;
|
|
161
|
+
/**
|
|
162
|
+
* Save is a commit: under a locked {@link WriteLockProvider} it is disabled with the
|
|
163
|
+
* lock's reason (over `submitDisabledReason`), focusable, the reason in its tooltip —
|
|
164
|
+
* Button's `commit`. The `{ label, onClick }` form of {@link destructive} is a
|
|
165
|
+
* commit too and is locked with it; Cancel and `children` are not (closing a form
|
|
166
|
+
* writes nothing). No provider, or an unlocked one: no effect. Default `false`.
|
|
167
|
+
*
|
|
168
|
+
* keksdose wrote `submitDisabled={lock.locked || …}` and
|
|
169
|
+
* `submitDisabledReason={lock.locked ? lock.reason : undefined}` at each form; this is
|
|
170
|
+
* that pair, read from the provider.
|
|
171
|
+
*/
|
|
172
|
+
commit?: boolean;
|
|
160
173
|
/** The save button's variant. Default `brand`; `danger` for a save that destroys
|
|
161
174
|
* (kastlan's `destructive` flag on its own FormActions). */
|
|
162
175
|
submitVariant?: ButtonVariant;
|
|
@@ -187,7 +200,8 @@ export interface FormActionsProps extends Omit<ComponentPropsWithoutRef<"div">,
|
|
|
187
200
|
* from both sides, with the content scrolling past it in the gutters (keksdose wrote
|
|
188
201
|
* `@max-md:-mx-3 @max-md:px-3` by hand). Pass the container's padding (`"0.75rem"`,
|
|
189
202
|
* `12`, `"var(--pane-px)"`). Applied only while the resolved placement is sticky, so
|
|
190
|
-
* `placement={{ base: "sticky", md: "inline" }}` bleeds on the phone alone.
|
|
203
|
+
* `placement={{ base: "sticky", md: "inline" }}` bleeds on the phone alone. The margin
|
|
204
|
+
* is `calc(-1 * X)`; jsdom folds that to `calc(-X)`, so match it loosely in tests.
|
|
191
205
|
*/
|
|
192
206
|
bleed?: string | number;
|
|
193
207
|
/**
|
|
@@ -249,6 +263,7 @@ export function FormActions({
|
|
|
249
263
|
pending = false,
|
|
250
264
|
submitDisabled = false,
|
|
251
265
|
submitDisabledReason,
|
|
266
|
+
commit = false,
|
|
252
267
|
submitVariant = "brand",
|
|
253
268
|
destructive,
|
|
254
269
|
start: startSlot,
|
|
@@ -276,6 +291,7 @@ export function FormActions({
|
|
|
276
291
|
onClick={destructive.onClick}
|
|
277
292
|
disabled={destructive.disabled}
|
|
278
293
|
disabledReason={destructive.disabledReason}
|
|
294
|
+
commit={commit}
|
|
279
295
|
>
|
|
280
296
|
{destructive.label}
|
|
281
297
|
</Button>
|
|
@@ -332,6 +348,9 @@ export function FormActions({
|
|
|
332
348
|
variant={submitVariant}
|
|
333
349
|
disabled={pending || submitDisabled}
|
|
334
350
|
disabledReason={pending ? undefined : submitDisabledReason}
|
|
351
|
+
// Not while pending: the save already left, and the lock's look over the
|
|
352
|
+
// spinner would say it had not.
|
|
353
|
+
commit={commit && !pending}
|
|
335
354
|
aria-busy={pending || undefined}
|
|
336
355
|
>
|
|
337
356
|
{pending ? (
|
|
@@ -154,26 +154,27 @@ const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
|
|
|
154
154
|
* digit grouping — "1.000" would read as a thousand in German and as one in English,
|
|
155
155
|
* and a field that parses its own display back must not have that ambiguity.
|
|
156
156
|
*/
|
|
157
|
-
export function NumberField({
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
157
|
+
export function NumberField(props: NumberFieldProps) {
|
|
158
|
+
const {
|
|
159
|
+
value,
|
|
160
|
+
onCommit,
|
|
161
|
+
onValueChange,
|
|
162
|
+
digits,
|
|
163
|
+
min,
|
|
164
|
+
max,
|
|
165
|
+
nullable = false,
|
|
166
|
+
unit,
|
|
167
|
+
unitPlacement = "suffix",
|
|
168
|
+
locale: localeProp,
|
|
169
|
+
label,
|
|
170
|
+
invalid,
|
|
171
|
+
error,
|
|
172
|
+
id,
|
|
173
|
+
"aria-describedby": ariaDescribedBy,
|
|
174
|
+
"aria-invalid": ariaInvalid,
|
|
175
|
+
"aria-required": ariaRequired,
|
|
176
|
+
...rest
|
|
177
|
+
} = props;
|
|
177
178
|
const locale = useKitLocale(localeProp);
|
|
178
179
|
const mark = decimalMark(locale);
|
|
179
180
|
const localize = (text: string) => (mark === "," ? text.replace(/\./g, ",") : text);
|
|
@@ -292,15 +293,20 @@ export function NumberField({
|
|
|
292
293
|
invalid={invalid || hasError || ariaInvalid === true || ariaInvalid === "true"}
|
|
293
294
|
/>
|
|
294
295
|
);
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
|
|
296
|
+
// Input's FieldGroup, restated: no wrapper for a field that never takes `error`, so
|
|
297
|
+
// the prop changed nothing about the DOM a flex row lays out — and a STEADY one for a
|
|
298
|
+
// field that does, message or not. Wrapping only while a message showed swapped the
|
|
299
|
+
// element above the <input>, which remounted it: focus, caret and the half-typed
|
|
300
|
+
// draft's keystrokes were lost the moment the message came or went.
|
|
301
|
+
if (!hasError && !("error" in props)) return field;
|
|
298
302
|
return (
|
|
299
303
|
<div>
|
|
300
304
|
{field}
|
|
301
|
-
|
|
302
|
-
{
|
|
303
|
-
|
|
305
|
+
{hasError && (
|
|
306
|
+
<p id={errorId} className={FIELD_ERROR_CLASS}>
|
|
307
|
+
{error}
|
|
308
|
+
</p>
|
|
309
|
+
)}
|
|
304
310
|
</div>
|
|
305
311
|
);
|
|
306
312
|
}
|
|
@@ -42,6 +42,32 @@ export interface PasskeysSettingProps<Id extends PasskeyId = PasskeyId> extends
|
|
|
42
42
|
* the user cancelling the OS prompt) keeps what was typed.
|
|
43
43
|
*/
|
|
44
44
|
onAdd: (name: string) => void | Promise<unknown>;
|
|
45
|
+
/**
|
|
46
|
+
* A step the app runs BEFORE `onAdd` (0.18.0) — re-asking for the current password,
|
|
47
|
+
* a policy notice, anything that may stop the add. Called with the same trimmed name;
|
|
48
|
+
* resolve `true` to go on to `onAdd`, `false` to stop quietly (nothing is said, the
|
|
49
|
+
* typed name stays). A rejection, or a throw, stops the same way: the app reports
|
|
50
|
+
* its own errors. While it runs the add button is disabled and busy, so a second
|
|
51
|
+
* click cannot start a second check.
|
|
52
|
+
*
|
|
53
|
+
* Kurvenschmiede asked for the password in its own dialog by handing `onAdd` a
|
|
54
|
+
* promise it kept open across that dialog (passkeys-card.tsx) — which still works.
|
|
55
|
+
* This is the documented place for it: open the dialog here, resolve with whether it
|
|
56
|
+
* was confirmed, and keep what it produced (the server's registration options) for
|
|
57
|
+
* `onAdd` in a ref. The ceremony then starts from the dialog's click rather than the
|
|
58
|
+
* card's, which is the user activation `navigator.credentials.create` wants.
|
|
59
|
+
*
|
|
60
|
+
* Left out, `onAdd` is called synchronously in the click, exactly as before.
|
|
61
|
+
*/
|
|
62
|
+
beforeAdd?: (name: string) => boolean | Promise<boolean>;
|
|
63
|
+
/**
|
|
64
|
+
* What a passkey does to the password, which picks the line under the title (0.18.0).
|
|
65
|
+
* `"instead"` (default): it replaces it — `labels.description`, kastlan's promise.
|
|
66
|
+
* `"alongside"`: the password stays and the passkey is a second way in —
|
|
67
|
+
* `labels.descriptionAlongside`. Kurvenschmiede overrode `description` for exactly
|
|
68
|
+
* this; an app that sets `mode` needs no override.
|
|
69
|
+
*/
|
|
70
|
+
mode?: "instead" | "alongside";
|
|
45
71
|
/** The ceremony is running. The add button says so and does not start a second one. */
|
|
46
72
|
adding?: boolean;
|
|
47
73
|
/** Offer "Rename" on each row. Return a promise to leave edit mode only on success. */
|
|
@@ -92,6 +118,8 @@ export function PasskeysSetting<Id extends PasskeyId = PasskeyId>({
|
|
|
92
118
|
passkeys,
|
|
93
119
|
loading,
|
|
94
120
|
onAdd,
|
|
121
|
+
beforeAdd,
|
|
122
|
+
mode = "instead",
|
|
95
123
|
adding = false,
|
|
96
124
|
onRename,
|
|
97
125
|
onDelete,
|
|
@@ -108,6 +136,10 @@ export function PasskeysSetting<Id extends PasskeyId = PasskeyId>({
|
|
|
108
136
|
const labels = useAccountSettingsLabels("passkeys", labelsProp);
|
|
109
137
|
const locale = useKitLocale(localeProp);
|
|
110
138
|
const [name, setName] = useState("");
|
|
139
|
+
// `beforeAdd` is running. State for the button, and a ref for the guard: two clicks
|
|
140
|
+
// in one frame both read the state from before either set it.
|
|
141
|
+
const [checking, setChecking] = useState(false);
|
|
142
|
+
const checkingRef = useRef(false);
|
|
111
143
|
|
|
112
144
|
const date = (value: PasskeyItem["createdAt"]): string | null => {
|
|
113
145
|
if (value === null || value === undefined || value === "") return null;
|
|
@@ -117,13 +149,40 @@ export function PasskeysSetting<Id extends PasskeyId = PasskeyId>({
|
|
|
117
149
|
};
|
|
118
150
|
|
|
119
151
|
const add = () => {
|
|
120
|
-
if (adding) return;
|
|
152
|
+
if (adding || checkingRef.current) return;
|
|
121
153
|
const typed = name;
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
.
|
|
125
|
-
|
|
154
|
+
const value = typed.trim();
|
|
155
|
+
const send = () =>
|
|
156
|
+
Promise.resolve(onAdd(value))
|
|
157
|
+
// Clear only what was sent: the field stays editable while the OS prompt is up.
|
|
158
|
+
.then(() => setName((current) => (current === typed ? "" : current)));
|
|
159
|
+
if (!beforeAdd) {
|
|
160
|
+
send().catch(() => {});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const done = () => {
|
|
164
|
+
checkingRef.current = false;
|
|
165
|
+
setChecking(false);
|
|
166
|
+
};
|
|
167
|
+
checkingRef.current = true;
|
|
168
|
+
setChecking(true);
|
|
169
|
+
let gate: Promise<boolean>;
|
|
170
|
+
// Called in the click, not a microtask later, so a step that itself needs the
|
|
171
|
+
// user's activation (a popup, the clipboard) still has it; a throw is a "no".
|
|
172
|
+
try {
|
|
173
|
+
gate = Promise.resolve(beforeAdd(value));
|
|
174
|
+
} catch (error) {
|
|
175
|
+
gate = Promise.reject(error);
|
|
176
|
+
}
|
|
177
|
+
gate
|
|
178
|
+
.then((go) => {
|
|
179
|
+
done();
|
|
180
|
+
// Strictly `true`: an app whose step resolves `undefined` meant nothing.
|
|
181
|
+
if (go === true) return send();
|
|
182
|
+
})
|
|
183
|
+
.catch(done);
|
|
126
184
|
};
|
|
185
|
+
const busy = adding || checking;
|
|
127
186
|
|
|
128
187
|
const isLoading = loading || passkeys === undefined;
|
|
129
188
|
const items = passkeys ?? [];
|
|
@@ -171,7 +230,7 @@ export function PasskeysSetting<Id extends PasskeyId = PasskeyId>({
|
|
|
171
230
|
}}
|
|
172
231
|
/>
|
|
173
232
|
)}
|
|
174
|
-
<Button onClick={add} disabled={
|
|
233
|
+
<Button onClick={add} disabled={busy} aria-busy={busy || undefined}>
|
|
175
234
|
<KeyRound className="size-4" aria-hidden />
|
|
176
235
|
{adding ? labels.adding : labels.add}
|
|
177
236
|
</Button>
|
|
@@ -186,7 +245,9 @@ export function PasskeysSetting<Id extends PasskeyId = PasskeyId>({
|
|
|
186
245
|
<div>
|
|
187
246
|
<div className="text-sm font-medium">{labels.title}</div>
|
|
188
247
|
<div className="text-xs text-[var(--text-muted)]">
|
|
189
|
-
{
|
|
248
|
+
{mode === "alongside"
|
|
249
|
+
? labels.descriptionAlongside
|
|
250
|
+
: labels.description}
|
|
190
251
|
</div>
|
|
191
252
|
</div>
|
|
192
253
|
{body}
|