@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.
Files changed (167) hide show
  1. package/README.md +29 -4
  2. package/dist/chart.d.ts +10 -1
  3. package/dist/components/account-chips.d.ts +153 -0
  4. package/dist/components/account-chips.js +135 -0
  5. package/dist/components/account-chips.js.map +1 -0
  6. package/dist/components/account-settings-labels.d.ts +10 -2
  7. package/dist/components/account-settings-labels.js +3 -0
  8. package/dist/components/account-settings-labels.js.map +1 -1
  9. package/dist/components/amount-input.d.ts +10 -1
  10. package/dist/components/button-group.d.ts +10 -1
  11. package/dist/components/calculator.d.ts +10 -1
  12. package/dist/components/chip.d.ts +25 -1
  13. package/dist/components/chip.js +143 -6
  14. package/dist/components/chip.js.map +1 -1
  15. package/dist/components/confirm-dialog.d.ts +42 -1
  16. package/dist/components/confirm-dialog.js +36 -5
  17. package/dist/components/confirm-dialog.js.map +1 -1
  18. package/dist/components/copy-button.d.ts +10 -1
  19. package/dist/components/danger-confirm.d.ts +55 -4
  20. package/dist/components/danger-confirm.js +67 -30
  21. package/dist/components/danger-confirm.js.map +1 -1
  22. package/dist/components/data-table-cells.d.ts +1 -1
  23. package/dist/components/data-table-filter-popover.d.ts +1 -1
  24. package/dist/components/data-table-filters.d.ts +1 -1
  25. package/dist/components/data-table.d.ts +1 -1
  26. package/dist/components/data-table.js +2 -0
  27. package/dist/components/data-table.js.map +1 -1
  28. package/dist/components/facing-pair.d.ts +10 -1
  29. package/dist/components/file-button.d.ts +10 -1
  30. package/dist/components/file-dropzone.d.ts +10 -1
  31. package/dist/components/form-actions.d.ts +10 -1
  32. package/dist/components/form-actions.js +3 -0
  33. package/dist/components/form-actions.js.map +1 -1
  34. package/dist/components/money-field.d.ts +10 -1
  35. package/dist/components/number-field.d.ts +11 -2
  36. package/dist/components/number-field.js +23 -22
  37. package/dist/components/number-field.js.map +1 -1
  38. package/dist/components/number-input.d.ts +10 -1
  39. package/dist/components/numpad-sheet.d.ts +10 -1
  40. package/dist/components/passkeys-setting.d.ts +27 -1
  41. package/dist/components/passkeys-setting.js +31 -5
  42. package/dist/components/passkeys-setting.js.map +1 -1
  43. package/dist/components/progress-bar.d.ts +15 -1
  44. package/dist/components/progress-bar.js +8 -1
  45. package/dist/components/progress-bar.js.map +1 -1
  46. package/dist/components/reauth-dialog.d.ts +90 -0
  47. package/dist/components/reauth-dialog.js +158 -0
  48. package/dist/components/reauth-dialog.js.map +1 -0
  49. package/dist/components/series-chart.d.ts +10 -1
  50. package/dist/components/server-wake.d.ts +58 -0
  51. package/dist/components/server-wake.js +58 -0
  52. package/dist/components/server-wake.js.map +1 -0
  53. package/dist/components/settings-fields.d.ts +10 -1
  54. package/dist/components/share-card.d.ts +61 -0
  55. package/dist/components/share-card.js +350 -0
  56. package/dist/components/share-card.js.map +1 -0
  57. package/dist/components/status-dot.d.ts +2 -0
  58. package/dist/components/text-link.d.ts +10 -1
  59. package/dist/components/time-input.d.ts +10 -1
  60. package/dist/components/toast.d.ts +10 -1
  61. package/dist/components/toast.js +42 -2
  62. package/dist/components/toast.js.map +1 -1
  63. package/dist/components/toggle-group.js +5 -2
  64. package/dist/components/toggle-group.js.map +1 -1
  65. package/dist/components/ui.d.ts +10 -1
  66. package/dist/components/ui.js +43 -28
  67. package/dist/components/ui.js.map +1 -1
  68. package/dist/components/use-table-state.d.ts +1 -1
  69. package/dist/components/user-avatar.d.ts +2 -0
  70. package/dist/components/write-lock.d.ts +68 -0
  71. package/dist/components/write-lock.js +33 -0
  72. package/dist/components/write-lock.js.map +1 -0
  73. package/dist/{data-table-CpkWUJBY.d.ts → data-table-3mi2R3so.d.ts} +19 -1
  74. package/dist/data-table.d.ts +1 -1
  75. package/dist/hooks/use-file-drop.d.ts +10 -1
  76. package/dist/hooks/use-overlay-history.js +16 -0
  77. package/dist/hooks/use-overlay-history.js.map +1 -1
  78. package/dist/i18n/defaults.d.ts +10 -1
  79. package/dist/i18n/defaults.js +11 -1
  80. package/dist/i18n/defaults.js.map +1 -1
  81. package/dist/i18n/german.d.ts +78 -0
  82. package/dist/i18n/{locales/de.js → german.js} +59 -9
  83. package/dist/i18n/german.js.map +1 -0
  84. package/dist/i18n/kit-labels.d.ts +10 -1
  85. package/dist/i18n/kit-labels.js +5 -0
  86. package/dist/i18n/kit-labels.js.map +1 -1
  87. package/dist/i18n/locales/de-CH.d.ts +23 -8
  88. package/dist/i18n/locales/de-CH.js +7 -3
  89. package/dist/i18n/locales/de-CH.js.map +1 -1
  90. package/dist/i18n/locales/en.d.ts +10 -1
  91. package/dist/i18n/locales/es.d.ts +10 -1
  92. package/dist/i18n/locales/es.js +51 -1
  93. package/dist/i18n/locales/es.js.map +1 -1
  94. package/dist/i18n/locales/fr.d.ts +10 -1
  95. package/dist/i18n/locales/fr.js +51 -1
  96. package/dist/i18n/locales/fr.js.map +1 -1
  97. package/dist/i18n/locales/hu.d.ts +10 -1
  98. package/dist/i18n/locales/hu.js +54 -1
  99. package/dist/i18n/locales/hu.js.map +1 -1
  100. package/dist/i18n/locales/it.d.ts +10 -1
  101. package/dist/i18n/locales/it.js +51 -1
  102. package/dist/i18n/locales/it.js.map +1 -1
  103. package/dist/i18n/locales/zh.d.ts +10 -1
  104. package/dist/i18n/locales/zh.js +50 -1
  105. package/dist/i18n/locales/zh.js.map +1 -1
  106. package/dist/i18n/swiss.d.ts +3 -3
  107. package/dist/i18n/swiss.js.map +1 -1
  108. package/dist/index.d.ts +9 -4
  109. package/dist/index.js +33 -1
  110. package/dist/index.js.map +1 -1
  111. package/dist/{kit-labels-C4uB_zmg.d.ts → kit-labels-CPbsjlXr.d.ts} +229 -9
  112. package/dist/lib/server-wake.d.ts +130 -0
  113. package/dist/lib/server-wake.js +121 -0
  114. package/dist/lib/server-wake.js.map +1 -0
  115. package/dist/rhf/fields.d.ts +10 -1
  116. package/dist/rhf/form.d.ts +10 -1
  117. package/dist/rhf.d.ts +10 -1
  118. package/dist/search/global-search.d.ts +3 -1
  119. package/dist/search/global-search.js.map +1 -1
  120. package/dist/shell/app-shell.d.ts +10 -1
  121. package/dist/shell/top-bar-brand.d.ts +10 -1
  122. package/dist/shell/topbar-action-menu.d.ts +2 -0
  123. package/dist/shell.d.ts +10 -1
  124. package/dist/wizard/stepper-nav.d.ts +10 -1
  125. package/dist/wizard.d.ts +10 -1
  126. package/package.json +1 -1
  127. package/src/components/account-chips.tsx +302 -0
  128. package/src/components/account-settings-labels.ts +16 -2
  129. package/src/components/chip.tsx +239 -6
  130. package/src/components/confirm-dialog.tsx +82 -7
  131. package/src/components/danger-confirm.tsx +118 -33
  132. package/src/components/data-table.tsx +20 -0
  133. package/src/components/form-actions.tsx +22 -3
  134. package/src/components/number-field.tsx +32 -26
  135. package/src/components/passkeys-setting.tsx +68 -7
  136. package/src/components/progress-bar.tsx +21 -1
  137. package/src/components/reauth-dialog.tsx +273 -0
  138. package/src/components/server-wake.tsx +124 -0
  139. package/src/components/share-card.tsx +615 -0
  140. package/src/components/toast.tsx +72 -2
  141. package/src/components/toggle-group.tsx +5 -2
  142. package/src/components/ui.tsx +89 -32
  143. package/src/components/write-lock.tsx +96 -0
  144. package/src/hooks/use-overlay-history.ts +51 -0
  145. package/src/i18n/defaults.ts +10 -0
  146. package/src/i18n/{locales/de.ts → german.ts} +68 -13
  147. package/src/i18n/kit-labels.tsx +22 -2
  148. package/src/i18n/locales/de-CH.ts +17 -8
  149. package/src/i18n/locales/es.ts +51 -0
  150. package/src/i18n/locales/fr.ts +52 -0
  151. package/src/i18n/locales/hu.ts +55 -0
  152. package/src/i18n/locales/it.ts +51 -0
  153. package/src/i18n/locales/zh.ts +50 -0
  154. package/src/i18n/swiss.ts +3 -3
  155. package/src/index.ts +23 -1
  156. package/src/lib/server-wake.ts +270 -0
  157. package/src/search/global-search.tsx +3 -1
  158. package/dist/i18n/locales/de-CH-informal.d.ts +0 -61
  159. package/dist/i18n/locales/de-CH-informal.js +0 -8
  160. package/dist/i18n/locales/de-CH-informal.js.map +0 -1
  161. package/dist/i18n/locales/de-informal.d.ts +0 -82
  162. package/dist/i18n/locales/de-informal.js +0 -37
  163. package/dist/i18n/locales/de-informal.js.map +0 -1
  164. package/dist/i18n/locales/de.d.ts +0 -66
  165. package/dist/i18n/locales/de.js.map +0 -1
  166. package/src/i18n/locales/de-CH-informal.ts +0 -10
  167. 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
- export const DEFAULT_CONFIRM_DIALOG_LABELS: ConfirmDialogLabels = {
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?: "trim" | "exact";
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 [pending, setPending] = useState(false);
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 || (phraseMatch === "exact" ? typed : typed.trim()) === phrase;
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
- const result = onConfirm(requirePassword ? password : undefined);
210
- if (!result || typeof (result as Promise<unknown>).then !== "function") return;
211
- setPending(true);
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
- <Input
399
+ <CurrentPasswordInput
313
400
  // The phrase field takes the ref when both are there — it comes first.
314
- ref={phrase === undefined ? firstFieldRef : undefined}
315
- type="password"
316
- autoComplete="current-password"
401
+ inputRef={phrase === undefined ? firstFieldRef : undefined}
317
402
  label={labels.password}
318
403
  value={password}
319
- readOnly={busy}
320
- onChange={(e) => setPassword(e.target.value)}
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
- value,
159
- onCommit,
160
- onValueChange,
161
- digits,
162
- min,
163
- max,
164
- nullable = false,
165
- unit,
166
- unitPlacement = "suffix",
167
- locale: localeProp,
168
- label,
169
- invalid,
170
- error,
171
- id,
172
- "aria-describedby": ariaDescribedBy,
173
- "aria-invalid": ariaInvalid,
174
- "aria-required": ariaRequired,
175
- ...rest
176
- }: NumberFieldProps) {
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
- // Same shape as Input's FieldGroup: no wrapper at all without a message, so adding
296
- // `error` support changed nothing about the DOM a flex row lays out.
297
- if (!hasError) return field;
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
- <p id={errorId} className={FIELD_ERROR_CLASS}>
302
- {error}
303
- </p>
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
- Promise.resolve(onAdd(typed.trim()))
123
- // Clear only what was sent: the field stays editable while the OS prompt is up.
124
- .then(() => setName((current) => (current === typed ? "" : current)))
125
- .catch(() => {});
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={adding} aria-busy={adding || undefined}>
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
- {labels.description}
248
+ {mode === "alongside"
249
+ ? labels.descriptionAlongside
250
+ : labels.description}
190
251
  </div>
191
252
  </div>
192
253
  {body}