@eifi1/ui-kit 0.17.0 → 0.19.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 (210) hide show
  1. package/README.md +83 -4
  2. package/dist/chart.d.ts +13 -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 +13 -1
  10. package/dist/components/button-group.d.ts +13 -1
  11. package/dist/components/calculator.d.ts +13 -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 +13 -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 +13 -1
  29. package/dist/components/file-button.d.ts +13 -1
  30. package/dist/components/file-dropzone.d.ts +13 -1
  31. package/dist/components/floating-panel.d.ts +4 -1
  32. package/dist/components/floating-panel.js +15 -1
  33. package/dist/components/floating-panel.js.map +1 -1
  34. package/dist/components/form-actions.d.ts +13 -1
  35. package/dist/components/form-actions.js +3 -0
  36. package/dist/components/form-actions.js.map +1 -1
  37. package/dist/components/legal.d.ts +84 -0
  38. package/dist/components/legal.js +55 -0
  39. package/dist/components/legal.js.map +1 -0
  40. package/dist/components/money-field.d.ts +13 -1
  41. package/dist/components/number-field.d.ts +14 -2
  42. package/dist/components/number-field.js +23 -22
  43. package/dist/components/number-field.js.map +1 -1
  44. package/dist/components/number-input.d.ts +13 -1
  45. package/dist/components/numpad-sheet.d.ts +13 -1
  46. package/dist/components/passkeys-setting.d.ts +27 -1
  47. package/dist/components/passkeys-setting.js +31 -5
  48. package/dist/components/passkeys-setting.js.map +1 -1
  49. package/dist/components/progress-bar.d.ts +15 -1
  50. package/dist/components/progress-bar.js +8 -1
  51. package/dist/components/progress-bar.js.map +1 -1
  52. package/dist/components/reauth-dialog.d.ts +90 -0
  53. package/dist/components/reauth-dialog.js +158 -0
  54. package/dist/components/reauth-dialog.js.map +1 -0
  55. package/dist/components/series-chart.d.ts +13 -1
  56. package/dist/components/server-wake.d.ts +60 -0
  57. package/dist/components/server-wake.js +79 -0
  58. package/dist/components/server-wake.js.map +1 -0
  59. package/dist/components/settings-fields.d.ts +13 -1
  60. package/dist/components/share-card.d.ts +64 -0
  61. package/dist/components/share-card.js +350 -0
  62. package/dist/components/share-card.js.map +1 -0
  63. package/dist/components/status-dot.d.ts +2 -0
  64. package/dist/components/text-link.d.ts +13 -1
  65. package/dist/components/time-input.d.ts +13 -1
  66. package/dist/components/toast.d.ts +10 -1
  67. package/dist/components/toast.js +42 -2
  68. package/dist/components/toast.js.map +1 -1
  69. package/dist/components/toggle-group.js +5 -2
  70. package/dist/components/toggle-group.js.map +1 -1
  71. package/dist/components/translation-review-editor.d.ts +56 -0
  72. package/dist/components/translation-review-editor.js +159 -0
  73. package/dist/components/translation-review-editor.js.map +1 -0
  74. package/dist/components/translation-review-labels.d.ts +92 -0
  75. package/dist/components/translation-review-labels.js +67 -0
  76. package/dist/components/translation-review-labels.js.map +1 -0
  77. package/dist/components/translation-review-parts.d.ts +97 -0
  78. package/dist/components/translation-review-parts.js +163 -0
  79. package/dist/components/translation-review-parts.js.map +1 -0
  80. package/dist/components/translation-review.d.ts +94 -0
  81. package/dist/components/translation-review.js +344 -0
  82. package/dist/components/translation-review.js.map +1 -0
  83. package/dist/components/ui.d.ts +13 -1
  84. package/dist/components/ui.js +43 -28
  85. package/dist/components/ui.js.map +1 -1
  86. package/dist/components/use-table-state.d.ts +1 -1
  87. package/dist/components/user-avatar.d.ts +2 -0
  88. package/dist/components/write-lock.d.ts +68 -0
  89. package/dist/components/write-lock.js +33 -0
  90. package/dist/components/write-lock.js.map +1 -0
  91. package/dist/{data-table-CpkWUJBY.d.ts → data-table-3mi2R3so.d.ts} +19 -1
  92. package/dist/data-table.d.ts +1 -1
  93. package/dist/hooks/use-file-drop.d.ts +13 -1
  94. package/dist/hooks/use-overlay-history.js +16 -0
  95. package/dist/hooks/use-overlay-history.js.map +1 -1
  96. package/dist/i18n/defaults.d.ts +13 -1
  97. package/dist/i18n/defaults.js +15 -1
  98. package/dist/i18n/defaults.js.map +1 -1
  99. package/dist/i18n/german.d.ts +81 -0
  100. package/dist/i18n/{locales/de.js → german.js} +110 -9
  101. package/dist/i18n/german.js.map +1 -0
  102. package/dist/i18n/kit-labels.d.ts +13 -1
  103. package/dist/i18n/kit-labels.js +5 -0
  104. package/dist/i18n/kit-labels.js.map +1 -1
  105. package/dist/i18n/languages.d.ts +243 -0
  106. package/dist/i18n/languages.js +70 -0
  107. package/dist/i18n/languages.js.map +1 -0
  108. package/dist/i18n/locales/de-CH.d.ts +26 -8
  109. package/dist/i18n/locales/de-CH.js +7 -3
  110. package/dist/i18n/locales/de-CH.js.map +1 -1
  111. package/dist/i18n/locales/en.d.ts +13 -1
  112. package/dist/i18n/locales/en.js +8 -0
  113. package/dist/i18n/locales/en.js.map +1 -1
  114. package/dist/i18n/locales/es.d.ts +13 -1
  115. package/dist/i18n/locales/es.js +127 -27
  116. package/dist/i18n/locales/es.js.map +1 -1
  117. package/dist/i18n/locales/fr.d.ts +13 -1
  118. package/dist/i18n/locales/fr.js +101 -1
  119. package/dist/i18n/locales/fr.js.map +1 -1
  120. package/dist/i18n/locales/hu.d.ts +13 -1
  121. package/dist/i18n/locales/hu.js +105 -1
  122. package/dist/i18n/locales/hu.js.map +1 -1
  123. package/dist/i18n/locales/it.d.ts +13 -1
  124. package/dist/i18n/locales/it.js +134 -34
  125. package/dist/i18n/locales/it.js.map +1 -1
  126. package/dist/i18n/locales/zh.d.ts +13 -1
  127. package/dist/i18n/locales/zh.js +101 -1
  128. package/dist/i18n/locales/zh.js.map +1 -1
  129. package/dist/i18n/review.d.ts +102 -0
  130. package/dist/i18n/review.js +158 -0
  131. package/dist/i18n/review.js.map +1 -0
  132. package/dist/i18n/swiss.d.ts +3 -3
  133. package/dist/i18n/swiss.js.map +1 -1
  134. package/dist/index.d.ts +17 -4
  135. package/dist/index.js +97 -1
  136. package/dist/index.js.map +1 -1
  137. package/dist/{kit-labels-C4uB_zmg.d.ts → kit-labels-D1J2_jyD.d.ts} +236 -9
  138. package/dist/lib/floating-stack.d.ts +31 -0
  139. package/dist/lib/floating-stack.js +42 -0
  140. package/dist/lib/floating-stack.js.map +1 -0
  141. package/dist/lib/server-wake.d.ts +146 -0
  142. package/dist/lib/server-wake.js +122 -0
  143. package/dist/lib/server-wake.js.map +1 -0
  144. package/dist/lib/translation-review.d.ts +232 -0
  145. package/dist/lib/translation-review.js +194 -0
  146. package/dist/lib/translation-review.js.map +1 -0
  147. package/dist/rhf/fields.d.ts +13 -1
  148. package/dist/rhf/form.d.ts +13 -1
  149. package/dist/rhf.d.ts +13 -1
  150. package/dist/search/global-search.d.ts +3 -1
  151. package/dist/search/global-search.js.map +1 -1
  152. package/dist/shell/app-shell.d.ts +13 -1
  153. package/dist/shell/top-bar-brand.d.ts +13 -1
  154. package/dist/shell/topbar-action-menu.d.ts +2 -0
  155. package/dist/shell.d.ts +13 -1
  156. package/dist/wizard/stepper-nav.d.ts +13 -1
  157. package/dist/wizard.d.ts +13 -1
  158. package/package.json +1 -1
  159. package/src/components/account-chips.tsx +302 -0
  160. package/src/components/account-settings-labels.ts +16 -2
  161. package/src/components/chip.tsx +239 -6
  162. package/src/components/confirm-dialog.tsx +82 -7
  163. package/src/components/danger-confirm.tsx +118 -33
  164. package/src/components/data-table.tsx +20 -0
  165. package/src/components/floating-panel.tsx +19 -1
  166. package/src/components/form-actions.tsx +22 -3
  167. package/src/components/legal.tsx +159 -0
  168. package/src/components/number-field.tsx +32 -26
  169. package/src/components/passkeys-setting.tsx +68 -7
  170. package/src/components/progress-bar.tsx +21 -1
  171. package/src/components/reauth-dialog.tsx +273 -0
  172. package/src/components/server-wake.tsx +156 -0
  173. package/src/components/share-card.tsx +615 -0
  174. package/src/components/toast.tsx +72 -2
  175. package/src/components/toggle-group.tsx +5 -2
  176. package/src/components/translation-review-editor.tsx +261 -0
  177. package/src/components/translation-review-labels.ts +152 -0
  178. package/src/components/translation-review-parts.tsx +269 -0
  179. package/src/components/translation-review.tsx +502 -0
  180. package/src/components/ui.tsx +89 -32
  181. package/src/components/write-lock.tsx +96 -0
  182. package/src/hooks/use-overlay-history.ts +51 -0
  183. package/src/i18n/defaults.ts +14 -0
  184. package/src/i18n/{locales/de.ts → german.ts} +120 -13
  185. package/src/i18n/kit-labels.tsx +29 -2
  186. package/src/i18n/languages.ts +285 -0
  187. package/src/i18n/locales/de-CH.ts +17 -8
  188. package/src/i18n/locales/en.ts +8 -0
  189. package/src/i18n/locales/es.ts +128 -26
  190. package/src/i18n/locales/fr.ts +103 -0
  191. package/src/i18n/locales/hu.ts +107 -0
  192. package/src/i18n/locales/it.ts +135 -33
  193. package/src/i18n/locales/zh.ts +102 -0
  194. package/src/i18n/review.ts +193 -0
  195. package/src/i18n/swiss.ts +3 -3
  196. package/src/index.ts +86 -1
  197. package/src/lib/floating-stack.ts +66 -0
  198. package/src/lib/server-wake.ts +291 -0
  199. package/src/lib/translation-review.ts +465 -0
  200. package/src/search/global-search.tsx +3 -1
  201. package/dist/i18n/locales/de-CH-informal.d.ts +0 -61
  202. package/dist/i18n/locales/de-CH-informal.js +0 -8
  203. package/dist/i18n/locales/de-CH-informal.js.map +0 -1
  204. package/dist/i18n/locales/de-informal.d.ts +0 -82
  205. package/dist/i18n/locales/de-informal.js +0 -37
  206. package/dist/i18n/locales/de-informal.js.map +0 -1
  207. package/dist/i18n/locales/de.d.ts +0 -66
  208. package/dist/i18n/locales/de.js.map +0 -1
  209. package/src/i18n/locales/de-CH-informal.ts +0 -10
  210. 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>
@@ -1,4 +1,4 @@
1
- import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
1
+ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import type {
4
4
  AnchorHTMLAttributes,
@@ -15,7 +15,9 @@ import { X } from "lucide-react";
15
15
 
16
16
  import { cn } from "../lib/cn";
17
17
  import { dirOf, type Direction } from "../lib/direction";
18
+ import { registerFloating } from "../lib/floating-stack";
18
19
  import { useKitLabels, useKitLink } from "../i18n/kit-labels";
20
+ import { assignRef } from "./choice-parts";
19
21
  import { pickLinkRenderer } from "./text-link";
20
22
  import { Tooltip, type TooltipSide } from "./tooltip";
21
23
 
@@ -172,6 +174,9 @@ export interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTM
172
174
  *
173
175
  * Portalled, because `position: fixed` inside any transformed ancestor (an animated
174
176
  * page transition) is fixed to that ancestor, not to the screen.
177
+ *
178
+ * While mounted it reports its box (`lib/floating-stack.ts`), so `ServerWakeNotice`
179
+ * stacks above it rather than over it.
175
180
  */
176
181
  export function FloatingActionButton({
177
182
  label,
@@ -189,18 +194,30 @@ export function FloatingActionButton({
189
194
  style,
190
195
  type = "button",
191
196
  title,
197
+ ref,
192
198
  ...rest
193
199
  }: FloatingActionButtonProps) {
194
200
  const [marker, dir] = usePortalDir();
195
201
  const toggle = pressed !== undefined;
196
202
  const tip = tooltip === true ? label : tooltip === false || tooltip === "" ? undefined : tooltip;
197
203
  const wrapped = tip !== undefined && tip !== null;
204
+ // The fixed element — the button, or the wrapper when there is a tooltip.
205
+ const [fixed, setFixed] = useState<HTMLElement | null>(null);
206
+ useEffect(() => (fixed ? registerFloating(fixed) : undefined), [fixed]);
207
+ const buttonRef = useCallback(
208
+ (node: HTMLButtonElement | null) => {
209
+ assignRef(ref, node);
210
+ if (!wrapped) setFixed(node);
211
+ },
212
+ [ref, wrapped],
213
+ );
198
214
  // An empty title is "no tooltip", not an attribute with nothing in it. The kit
199
215
  // tooltip replaces it outright.
200
216
  const nativeTip = !wrapped && nativeTitle && title !== "" ? (title ?? label) : undefined;
201
217
  const button = (
202
218
  <button
203
219
  {...rest}
220
+ ref={buttonRef}
204
221
  type={type}
205
222
  dir={dir}
206
223
  aria-label={label}
@@ -248,6 +265,7 @@ export function FloatingActionButton({
248
265
  <>
249
266
  {wrapped ? (
250
267
  <div
268
+ ref={setFixed}
251
269
  dir={dir}
252
270
  hidden={rest.hidden}
253
271
  style={cornerStyle(corner, offset)}
@@ -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 ? (
@@ -0,0 +1,159 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { useKitLabels } from "../i18n/kit-labels";
4
+ import { cn } from "../lib/cn";
5
+ import { AlertBanner } from "./alert-banner";
6
+ import { TextLink } from "./text-link";
7
+
8
+ /**
9
+ * The legal pages' shell — Imprint, Privacy Policy, Terms — for every app (Marcel,
10
+ * 2026-10-02: every app has them; docs/i18n-harmonization.md H10).
11
+ *
12
+ * keksdose and kastlan each carried the same shell, written twice: a back link, the
13
+ * title, a "closed beta, not yet reviewed by a lawyer" notice, sections of heading +
14
+ * running text, and kastlan's row of links to the three pages with the current one
15
+ * marked. Kurvenschmiede had none. The words stay in each app — they are the app's
16
+ * legal statements, reviewed under the `legal` area of the translation review — and
17
+ * only the shape moves here.
18
+ *
19
+ * A CONTENT COLUMN, not a page: keksdose shows its legal pages inside its public
20
+ * landing header and footer, kastlan inside `AuthLayout` (`width="wide" card={false}`,
21
+ * no title of its own). Both put this inside their chrome; a page-owning layout would
22
+ * have fought one of them.
23
+ */
24
+
25
+ // ── Labels ────────────────────────────────────────────────────────────────────
26
+
27
+ export interface LegalLabels {
28
+ /** The accessible name of {@link LegalLinks}' `<nav>`. */
29
+ navLabel: string;
30
+ }
31
+
32
+ export const DEFAULT_LEGAL_LABELS: LegalLabels = {
33
+ navLabel: "Legal",
34
+ };
35
+
36
+ // ── Links ─────────────────────────────────────────────────────────────────────
37
+
38
+ export interface LegalLink {
39
+ href: string;
40
+ /** "Imprint", "Privacy Policy", "Terms" — the app's words. */
41
+ label: ReactNode;
42
+ }
43
+
44
+ export interface LegalLinksProps {
45
+ links: readonly LegalLink[];
46
+ /** The page being shown; its link is marked `aria-current="page"`. Usually the
47
+ * router's pathname. */
48
+ currentHref?: string;
49
+ /** `false` renders the links without the `<nav>` — inside `AuthLayout`'s `footer`,
50
+ * which wraps its footer in a `<nav>` named by `footerLabel` already. Default true. */
51
+ nav?: boolean;
52
+ labels?: Partial<LegalLabels>;
53
+ className?: string;
54
+ }
55
+
56
+ /** The legal pages as a row of quiet links, the one you are on marked (kastlan's
57
+ * `LegalLinks`). Wraps on a phone. */
58
+ export function LegalLinks({ links, currentHref, nav = true, labels: labelsProp, className }: LegalLinksProps) {
59
+ const labels = useKitLabels("legal", DEFAULT_LEGAL_LABELS, labelsProp);
60
+ const items = links.map((link) => (
61
+ <TextLink key={link.href} href={link.href} tone="muted" current={link.href === currentHref}>
62
+ {link.label}
63
+ </TextLink>
64
+ ));
65
+ if (!nav) return <>{items}</>;
66
+ return (
67
+ <nav
68
+ aria-label={labels.navLabel}
69
+ className={cn("flex flex-wrap gap-x-4 gap-y-1 text-xs", className)}
70
+ >
71
+ {items}
72
+ </nav>
73
+ );
74
+ }
75
+
76
+ // ── Section ───────────────────────────────────────────────────────────────────
77
+
78
+ export interface LegalSectionProps {
79
+ heading: ReactNode;
80
+ /** Running text. Line breaks in it are kept (`white-space: pre-line`), so a
81
+ * translation can hold an address or a list as plain lines. */
82
+ body?: string;
83
+ /** Anything richer than plain text — links, a list — after `body`. */
84
+ children?: ReactNode;
85
+ /** The section's `id`, for a link to it from elsewhere ("see §4"). */
86
+ id?: string;
87
+ /** The heading's element. Default `h2`, under the layout's `h1`. */
88
+ headingAs?: "h2" | "h3";
89
+ }
90
+
91
+ export function LegalSection({ heading, body, children, id, headingAs: Heading = "h2" }: LegalSectionProps) {
92
+ return (
93
+ <section id={id} className="scroll-mt-20">
94
+ <Heading className="text-lg font-semibold text-[var(--text-primary)]">{heading}</Heading>
95
+ {body !== undefined && (
96
+ <p className="mt-2 whitespace-pre-line text-sm leading-relaxed text-[var(--text-secondary)]">{body}</p>
97
+ )}
98
+ {children !== undefined && (
99
+ <div className="mt-2 text-sm leading-relaxed text-[var(--text-secondary)]">{children}</div>
100
+ )}
101
+ </section>
102
+ );
103
+ }
104
+
105
+ // ── Layout ────────────────────────────────────────────────────────────────────
106
+
107
+ export interface LegalLayoutProps {
108
+ /** "Imprint", "Privacy Policy", "Terms". */
109
+ title: ReactNode;
110
+ /** Above the title — the app's "← Back" link (`<TextLink icon={ArrowLeft}>`), whose
111
+ * target differs per app (keksdose's start page, kastlan's sign-in). */
112
+ back?: ReactNode;
113
+ /**
114
+ * A notice under the title, as a warning banner with `role="note"` — page furniture,
115
+ * not an alert. Both apps say their legal texts await a lawyer's review; keksdose's
116
+ * privacy page says something more precise than the others (its technical sections
117
+ * describe running code), so the notice is per page, not per layout.
118
+ */
119
+ notice?: ReactNode;
120
+ /** The sections — {@link LegalSection}s. */
121
+ children: ReactNode;
122
+ /** The three pages, linked at the end of the column with the current one marked.
123
+ * Leave out when the page's chrome already shows them (kastlan's AuthLayout footer). */
124
+ links?: readonly LegalLink[];
125
+ currentHref?: string;
126
+ /** Default `h1`: the page's heading. */
127
+ headingAs?: "h1" | "h2";
128
+ /** Extra classes on the column — `px-0 py-0` inside a chrome that pads already. */
129
+ className?: string;
130
+ labels?: Partial<LegalLabels>;
131
+ }
132
+
133
+ export function LegalLayout({
134
+ title,
135
+ back,
136
+ notice,
137
+ children,
138
+ links,
139
+ currentHref,
140
+ headingAs: Heading = "h1",
141
+ className,
142
+ labels,
143
+ }: LegalLayoutProps) {
144
+ return (
145
+ <article className={cn("mx-auto w-full max-w-3xl px-4 py-12", className)}>
146
+ {back !== undefined && <div className="mb-4 text-sm">{back}</div>}
147
+ <Heading className="text-3xl font-bold tracking-tight text-[var(--text-primary)]">{title}</Heading>
148
+ {notice !== undefined && notice !== null && (
149
+ <AlertBanner tone="warning" role="note" className="mt-4">
150
+ {notice}
151
+ </AlertBanner>
152
+ )}
153
+ <div className="mt-8 space-y-8">{children}</div>
154
+ {links !== undefined && links.length > 0 && (
155
+ <LegalLinks links={links} currentHref={currentHref} labels={labels} className="mt-12 border-t border-[var(--border)] pt-4" />
156
+ )}
157
+ </article>
158
+ );
159
+ }