@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.
- package/README.md +83 -4
- package/dist/chart.d.ts +13 -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 +13 -1
- package/dist/components/button-group.d.ts +13 -1
- package/dist/components/calculator.d.ts +13 -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 +13 -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 +13 -1
- package/dist/components/file-button.d.ts +13 -1
- package/dist/components/file-dropzone.d.ts +13 -1
- package/dist/components/floating-panel.d.ts +4 -1
- package/dist/components/floating-panel.js +15 -1
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/form-actions.d.ts +13 -1
- package/dist/components/form-actions.js +3 -0
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/legal.d.ts +84 -0
- package/dist/components/legal.js +55 -0
- package/dist/components/legal.js.map +1 -0
- package/dist/components/money-field.d.ts +13 -1
- package/dist/components/number-field.d.ts +14 -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 +13 -1
- package/dist/components/numpad-sheet.d.ts +13 -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 +13 -1
- package/dist/components/server-wake.d.ts +60 -0
- package/dist/components/server-wake.js +79 -0
- package/dist/components/server-wake.js.map +1 -0
- package/dist/components/settings-fields.d.ts +13 -1
- package/dist/components/share-card.d.ts +64 -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 +13 -1
- package/dist/components/time-input.d.ts +13 -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/translation-review-editor.d.ts +56 -0
- package/dist/components/translation-review-editor.js +159 -0
- package/dist/components/translation-review-editor.js.map +1 -0
- package/dist/components/translation-review-labels.d.ts +92 -0
- package/dist/components/translation-review-labels.js +67 -0
- package/dist/components/translation-review-labels.js.map +1 -0
- package/dist/components/translation-review-parts.d.ts +97 -0
- package/dist/components/translation-review-parts.js +163 -0
- package/dist/components/translation-review-parts.js.map +1 -0
- package/dist/components/translation-review.d.ts +94 -0
- package/dist/components/translation-review.js +344 -0
- package/dist/components/translation-review.js.map +1 -0
- package/dist/components/ui.d.ts +13 -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 +13 -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 +13 -1
- package/dist/i18n/defaults.js +15 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +81 -0
- package/dist/i18n/{locales/de.js → german.js} +110 -9
- package/dist/i18n/german.js.map +1 -0
- package/dist/i18n/kit-labels.d.ts +13 -1
- package/dist/i18n/kit-labels.js +5 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +243 -0
- package/dist/i18n/languages.js +70 -0
- package/dist/i18n/languages.js.map +1 -0
- package/dist/i18n/locales/de-CH.d.ts +26 -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 +13 -1
- package/dist/i18n/locales/en.js +8 -0
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +13 -1
- package/dist/i18n/locales/es.js +127 -27
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +13 -1
- package/dist/i18n/locales/fr.js +101 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +13 -1
- package/dist/i18n/locales/hu.js +105 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +13 -1
- package/dist/i18n/locales/it.js +134 -34
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +13 -1
- package/dist/i18n/locales/zh.js +101 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +102 -0
- package/dist/i18n/review.js +158 -0
- package/dist/i18n/review.js.map +1 -0
- package/dist/i18n/swiss.d.ts +3 -3
- package/dist/i18n/swiss.js.map +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.js +97 -1
- package/dist/index.js.map +1 -1
- package/dist/{kit-labels-C4uB_zmg.d.ts → kit-labels-D1J2_jyD.d.ts} +236 -9
- package/dist/lib/floating-stack.d.ts +31 -0
- package/dist/lib/floating-stack.js +42 -0
- package/dist/lib/floating-stack.js.map +1 -0
- package/dist/lib/server-wake.d.ts +146 -0
- package/dist/lib/server-wake.js +122 -0
- package/dist/lib/server-wake.js.map +1 -0
- package/dist/lib/translation-review.d.ts +232 -0
- package/dist/lib/translation-review.js +194 -0
- package/dist/lib/translation-review.js.map +1 -0
- package/dist/rhf/fields.d.ts +13 -1
- package/dist/rhf/form.d.ts +13 -1
- package/dist/rhf.d.ts +13 -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 +13 -1
- package/dist/shell/top-bar-brand.d.ts +13 -1
- package/dist/shell/topbar-action-menu.d.ts +2 -0
- package/dist/shell.d.ts +13 -1
- package/dist/wizard/stepper-nav.d.ts +13 -1
- package/dist/wizard.d.ts +13 -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/floating-panel.tsx +19 -1
- package/src/components/form-actions.tsx +22 -3
- package/src/components/legal.tsx +159 -0
- 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 +156 -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/translation-review-editor.tsx +261 -0
- package/src/components/translation-review-labels.ts +152 -0
- package/src/components/translation-review-parts.tsx +269 -0
- package/src/components/translation-review.tsx +502 -0
- 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 +14 -0
- package/src/i18n/{locales/de.ts → german.ts} +120 -13
- package/src/i18n/kit-labels.tsx +29 -2
- package/src/i18n/languages.ts +285 -0
- package/src/i18n/locales/de-CH.ts +17 -8
- package/src/i18n/locales/en.ts +8 -0
- package/src/i18n/locales/es.ts +128 -26
- package/src/i18n/locales/fr.ts +103 -0
- package/src/i18n/locales/hu.ts +107 -0
- package/src/i18n/locales/it.ts +135 -33
- package/src/i18n/locales/zh.ts +102 -0
- package/src/i18n/review.ts +193 -0
- package/src/i18n/swiss.ts +3 -3
- package/src/index.ts +86 -1
- package/src/lib/floating-stack.ts +66 -0
- package/src/lib/server-wake.ts +291 -0
- package/src/lib/translation-review.ts +465 -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>
|
|
@@ -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
|
+
}
|