@eifi1/ui-kit 0.16.1 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -9
- package/dist/chart.d.ts +10 -1
- package/dist/components/account-chips.d.ts +153 -0
- package/dist/components/account-chips.js +135 -0
- package/dist/components/account-chips.js.map +1 -0
- package/dist/components/account-settings-labels.d.ts +10 -2
- package/dist/components/account-settings-labels.js +3 -0
- package/dist/components/account-settings-labels.js.map +1 -1
- package/dist/components/amount-input.d.ts +18 -1
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/button-group.d.ts +10 -1
- package/dist/components/calculator.d.ts +10 -1
- package/dist/components/chip.d.ts +25 -1
- package/dist/components/chip.js +143 -6
- package/dist/components/chip.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +42 -1
- package/dist/components/confirm-dialog.js +36 -5
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.d.ts +10 -1
- package/dist/components/danger-confirm.d.ts +55 -4
- package/dist/components/danger-confirm.js +67 -30
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-cells.d.ts +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +93 -3
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/facing-pair.d.ts +10 -1
- package/dist/components/file-button.d.ts +10 -1
- package/dist/components/file-dropzone.d.ts +10 -1
- package/dist/components/form-actions.d.ts +10 -1
- package/dist/components/form-actions.js +35 -1
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/line-items.d.ts +30 -6
- package/dist/components/line-items.js +77 -33
- package/dist/components/line-items.js.map +1 -1
- package/dist/components/list.d.ts +13 -1
- package/dist/components/list.js.map +1 -1
- package/dist/components/money-field.d.ts +10 -1
- package/dist/components/number-field.d.ts +11 -2
- package/dist/components/number-field.js +23 -22
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +10 -1
- package/dist/components/numpad-sheet.d.ts +10 -1
- package/dist/components/passkeys-setting.d.ts +27 -1
- package/dist/components/passkeys-setting.js +31 -5
- package/dist/components/passkeys-setting.js.map +1 -1
- package/dist/components/progress-bar.d.ts +25 -1
- package/dist/components/progress-bar.js +21 -4
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/reauth-dialog.d.ts +90 -0
- package/dist/components/reauth-dialog.js +158 -0
- package/dist/components/reauth-dialog.js.map +1 -0
- package/dist/components/series-chart.d.ts +10 -1
- package/dist/components/server-wake.d.ts +58 -0
- package/dist/components/server-wake.js +58 -0
- package/dist/components/server-wake.js.map +1 -0
- package/dist/components/settings-fields.d.ts +10 -1
- package/dist/components/share-card.d.ts +61 -0
- package/dist/components/share-card.js +350 -0
- package/dist/components/share-card.js.map +1 -0
- package/dist/components/signed-amount.d.ts +12 -3
- package/dist/components/signed-amount.js +6 -4
- package/dist/components/signed-amount.js.map +1 -1
- package/dist/components/status-dot.d.ts +2 -0
- package/dist/components/text-link.d.ts +10 -1
- package/dist/components/time-input.d.ts +10 -1
- package/dist/components/toast.d.ts +10 -1
- package/dist/components/toast.js +42 -2
- package/dist/components/toast.js.map +1 -1
- package/dist/components/toggle-group.js +5 -2
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/ui.d.ts +10 -1
- package/dist/components/ui.js +67 -30
- 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-CfVXtCeG.d.ts → data-table-3mi2R3so.d.ts} +59 -1
- package/dist/data-table.d.ts +1 -1
- package/dist/hooks/use-file-drop.d.ts +10 -1
- package/dist/hooks/use-overlay-history.js +16 -0
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/i18n/defaults.d.ts +10 -1
- package/dist/i18n/defaults.js +11 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +78 -0
- package/dist/i18n/{locales/de.js → german.js} +59 -9
- package/dist/i18n/german.js.map +1 -0
- package/dist/i18n/kit-labels.d.ts +10 -1
- package/dist/i18n/kit-labels.js +5 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/de-CH.d.ts +23 -8
- package/dist/i18n/locales/de-CH.js +7 -3
- package/dist/i18n/locales/de-CH.js.map +1 -1
- package/dist/i18n/locales/{de.d.ts → en.d.ts} +24 -10
- package/dist/i18n/locales/en.js +95 -0
- package/dist/i18n/locales/en.js.map +1 -0
- package/dist/i18n/locales/es.d.ts +10 -1
- package/dist/i18n/locales/es.js +51 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +10 -1
- package/dist/i18n/locales/fr.js +51 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +10 -1
- package/dist/i18n/locales/hu.js +54 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +10 -1
- package/dist/i18n/locales/it.js +51 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +10 -1
- package/dist/i18n/locales/zh.js +50 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/swiss.d.ts +3 -3
- package/dist/i18n/swiss.js.map +1 -1
- package/dist/index.d.ts +11 -6
- package/dist/index.js +33 -1
- package/dist/index.js.map +1 -1
- package/dist/{kit-labels-DE--LmCk.d.ts → kit-labels-CPbsjlXr.d.ts} +282 -10
- package/dist/lib/server-wake.d.ts +130 -0
- package/dist/lib/server-wake.js +121 -0
- package/dist/lib/server-wake.js.map +1 -0
- package/dist/rhf/fields.d.ts +10 -1
- package/dist/rhf/form.d.ts +10 -1
- package/dist/rhf/line-items.js.map +1 -1
- package/dist/rhf.d.ts +10 -1
- package/dist/search/global-search.d.ts +24 -4
- package/dist/search/global-search.js +31 -3
- package/dist/search/global-search.js.map +1 -1
- package/dist/search.d.ts +1 -1
- package/dist/shell/app-shell.d.ts +10 -1
- package/dist/shell/top-bar-brand.d.ts +10 -1
- package/dist/shell/topbar-action-menu.d.ts +2 -0
- package/dist/shell.d.ts +10 -1
- package/dist/wizard/stepper-nav.d.ts +10 -1
- package/dist/wizard.d.ts +10 -1
- package/package.json +1 -1
- package/src/components/account-chips.tsx +302 -0
- package/src/components/account-settings-labels.ts +16 -2
- package/src/components/amount-input.tsx +8 -0
- 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 +185 -3
- package/src/components/form-actions.tsx +93 -4
- package/src/components/line-items.tsx +116 -40
- package/src/components/list.tsx +13 -1
- package/src/components/number-field.tsx +32 -26
- package/src/components/passkeys-setting.tsx +68 -7
- package/src/components/progress-bar.tsx +40 -4
- package/src/components/reauth-dialog.tsx +273 -0
- package/src/components/server-wake.tsx +124 -0
- package/src/components/share-card.tsx +615 -0
- package/src/components/signed-amount.tsx +16 -5
- package/src/components/toast.tsx +72 -2
- package/src/components/toggle-group.tsx +5 -2
- package/src/components/ui.tsx +145 -34
- package/src/components/write-lock.tsx +96 -0
- package/src/hooks/use-overlay-history.ts +51 -0
- package/src/i18n/defaults.ts +10 -0
- package/src/i18n/{locales/de.ts → german.ts} +68 -13
- package/src/i18n/kit-labels.tsx +22 -2
- package/src/i18n/locales/de-CH.ts +17 -8
- package/src/i18n/locales/en.ts +116 -0
- package/src/i18n/locales/es.ts +51 -0
- package/src/i18n/locales/fr.ts +52 -0
- package/src/i18n/locales/hu.ts +55 -0
- package/src/i18n/locales/it.ts +51 -0
- package/src/i18n/locales/zh.ts +50 -0
- package/src/i18n/swiss.ts +3 -3
- package/src/index.ts +23 -1
- package/src/lib/server-wake.ts +270 -0
- package/src/rhf/line-items.tsx +2 -1
- package/src/search/global-search.tsx +59 -6
- 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.js.map +0 -1
- package/src/i18n/locales/de-CH-informal.ts +0 -10
- package/src/i18n/locales/de-informal.ts +0 -61
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { useContext, useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
import type { KeyboardEvent, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { useKitLabels } from "../i18n/kit-labels";
|
|
5
|
+
import { usePromisePending } from "./danger-confirm";
|
|
6
|
+
import { DialogFrame } from "./dialog-frame";
|
|
7
|
+
import { ModalCloseContext } from "./modal";
|
|
8
|
+
import { Button, Input, Spinner } from "./ui";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Every string the dialog renders — the `reauthDialog` namespace of
|
|
12
|
+
* `<UiKitProvider labels>`, and overridable per instance through `labels`. The title,
|
|
13
|
+
* description and submit button are usually worded per action ("Add a passkey"),
|
|
14
|
+
* which is what the `title` / `description` / `submitLabel` props are for; these are
|
|
15
|
+
* the fallbacks.
|
|
16
|
+
*/
|
|
17
|
+
export interface ReauthDialogLabels {
|
|
18
|
+
title: string;
|
|
19
|
+
description: string;
|
|
20
|
+
/** Label of the password field. */
|
|
21
|
+
password: string;
|
|
22
|
+
submit: string;
|
|
23
|
+
cancel: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_REAUTH_DIALOG_LABELS: ReauthDialogLabels = {
|
|
27
|
+
title: "Confirm it’s you",
|
|
28
|
+
description: "Enter your current password to continue.",
|
|
29
|
+
password: "Current password",
|
|
30
|
+
submit: "Continue",
|
|
31
|
+
cancel: "Cancel",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface ReauthDialogProps {
|
|
35
|
+
/**
|
|
36
|
+
* Verifies the password — the app's call, e.g. the request the password guards.
|
|
37
|
+
*
|
|
38
|
+
* Return a promise and the dialog manages itself: busy until it settles, closes
|
|
39
|
+
* (`onClose(true)`) when it resolves, STAYS OPEN with the password kept and selected
|
|
40
|
+
* when it rejects — a wrong password is a typo to correct, not a sign-out. Pass the
|
|
41
|
+
* reason as `error`. Return nothing and the app owns the rest: `busy` for the
|
|
42
|
+
* pending state, unmounting the dialog once it is done.
|
|
43
|
+
*/
|
|
44
|
+
onSubmit: (password: string) => void | Promise<unknown>;
|
|
45
|
+
/**
|
|
46
|
+
* The dialog has closed: `true` after a resolved `onSubmit`, `false` for Cancel,
|
|
47
|
+
* Escape, the backdrop and Back. A dismissal while `onSubmit` is still running also
|
|
48
|
+
* answers `false`, and that call's late result is then ignored.
|
|
49
|
+
*/
|
|
50
|
+
onClose: (confirmed: boolean) => void;
|
|
51
|
+
/**
|
|
52
|
+
* Why the last attempt failed ("Wrong password"), shown under the field, which turns
|
|
53
|
+
* invalid (`aria-invalid`, `--danger-border-strong`). The app decides the wording —
|
|
54
|
+
* a 400 is a wrong password, a 500 is not. Hidden again as soon as the user edits
|
|
55
|
+
* the field (the message is about the password they had typed), and shown again by
|
|
56
|
+
* the next failed submit.
|
|
57
|
+
*/
|
|
58
|
+
error?: ReactNode;
|
|
59
|
+
/** The check is running, for a caller that tracks it itself (a mutation's
|
|
60
|
+
* `isPending`); a promise returned from `onSubmit` does the same on its own. */
|
|
61
|
+
busy?: boolean;
|
|
62
|
+
/** Default: `labels.title` — "Confirm it’s you". */
|
|
63
|
+
title?: ReactNode;
|
|
64
|
+
/** The line under the title. Default: `labels.description`. */
|
|
65
|
+
description?: ReactNode;
|
|
66
|
+
/** Text of the submit button. Default: `labels.submit` — "Continue". */
|
|
67
|
+
submitLabel?: ReactNode;
|
|
68
|
+
/** `"danger"` when what the password unlocks cannot be undone: the submit is then the
|
|
69
|
+
* destructive button. Default `"neutral"`. */
|
|
70
|
+
tone?: "neutral" | "danger";
|
|
71
|
+
/** Extra body above the field — what is about to happen, a warning. */
|
|
72
|
+
children?: ReactNode;
|
|
73
|
+
/** Kept-mounted mode, as on `Modal`: left out, the dialog is open while mounted. A
|
|
74
|
+
* flip to `false` animates it out and wipes the password. */
|
|
75
|
+
open?: boolean;
|
|
76
|
+
/** User-facing strings; see {@link ReauthDialogLabels}. */
|
|
77
|
+
labels?: Partial<ReauthDialogLabels>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* "Enter your current password to continue" — the step-up check in front of an
|
|
82
|
+
* action that changes how an account signs in (adding a passkey) or what it can
|
|
83
|
+
* reach. Kurvenschmiede asked for the password before registering a passkey in a
|
|
84
|
+
* dialog of its own; keksdose asks for it inline in `DangerConfirm`'s
|
|
85
|
+
* `requirePassword` tile. Both are the same three facts — the field is the CURRENT
|
|
86
|
+
* password (the password manager fills it, never generates one), a wrong one stays in
|
|
87
|
+
* the dialog with its error, and the APP verifies it — so the promise handling is
|
|
88
|
+
* `DangerConfirm`'s own, shared, the field is its `CurrentPasswordInput` spelled out
|
|
89
|
+
* with an `error`, and this is the modal form of them.
|
|
90
|
+
*
|
|
91
|
+
* A component rather than a `useReauth()` promise: the step that follows the check
|
|
92
|
+
* (Kurvenschmiede's WebAuthn ceremony) needs what the server answered, and the error
|
|
93
|
+
* wording needs the app's own status codes — both already live in the app's mutation,
|
|
94
|
+
* which this dialog only has to be handed (`onSubmit={(pw) => begin.mutateAsync(pw)}`,
|
|
95
|
+
* `error={…}`).
|
|
96
|
+
*
|
|
97
|
+
* The field is a `<form>`, so Enter submits once something is typed. Focus starts in
|
|
98
|
+
* the field and returns to the trigger through `Modal`'s trap. While a check runs,
|
|
99
|
+
* the field is read-only, Cancel is disabled and Escape is held: a request that has
|
|
100
|
+
* left cannot be cancelled by closing the dialog, and the dialog should not pretend it
|
|
101
|
+
* can.
|
|
102
|
+
*/
|
|
103
|
+
export function ReauthDialog({
|
|
104
|
+
onSubmit,
|
|
105
|
+
onClose,
|
|
106
|
+
error,
|
|
107
|
+
busy: busyProp,
|
|
108
|
+
title,
|
|
109
|
+
description,
|
|
110
|
+
submitLabel,
|
|
111
|
+
tone = "neutral",
|
|
112
|
+
children,
|
|
113
|
+
open,
|
|
114
|
+
labels: labelsProp,
|
|
115
|
+
}: ReauthDialogProps) {
|
|
116
|
+
const labels = useKitLabels("reauthDialog", DEFAULT_REAUTH_DIALOG_LABELS, labelsProp);
|
|
117
|
+
const [password, setPassword] = useState("");
|
|
118
|
+
// The error describes the password that was submitted; once the user edits it, it
|
|
119
|
+
// describes nothing on screen. Cleared by the next submit, so a second failure with
|
|
120
|
+
// the same message shows again.
|
|
121
|
+
const [edited, setEdited] = useState(false);
|
|
122
|
+
const { pending, run } = usePromisePending();
|
|
123
|
+
const busy = Boolean(busyProp) || pending;
|
|
124
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
125
|
+
// `onSubmit` resolved: the actions row lowers the panel (state, so it re-renders),
|
|
126
|
+
// and `onClose` answers true (a ref, because `Modal` may call the `onClose` it saw
|
|
127
|
+
// BEFORE that re-render — its latest-callback ref is refreshed in an effect that runs
|
|
128
|
+
// after the actions row's).
|
|
129
|
+
const [done, setDone] = useState(false);
|
|
130
|
+
const confirmed = useRef(false);
|
|
131
|
+
const formId = useId();
|
|
132
|
+
const isOpen = open ?? true;
|
|
133
|
+
|
|
134
|
+
// A close (kept-mounted) wipes the password: it must not sit in a hidden dialog.
|
|
135
|
+
// During render, like DangerConfirm's disarm, so no frame holds it.
|
|
136
|
+
const [wasOpen, setWasOpen] = useState(isOpen);
|
|
137
|
+
if (wasOpen !== isOpen) {
|
|
138
|
+
setWasOpen(isOpen);
|
|
139
|
+
if (!isOpen) {
|
|
140
|
+
setPassword("");
|
|
141
|
+
setEdited(false);
|
|
142
|
+
setDone(false);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// After `Modal`'s own effect (a parent's effects run after its children's), so the
|
|
147
|
+
// trap has recorded the trigger as its restore target before focus moves in.
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
if (!isOpen) return;
|
|
150
|
+
confirmed.current = false;
|
|
151
|
+
inputRef.current?.focus();
|
|
152
|
+
}, [isOpen]);
|
|
153
|
+
|
|
154
|
+
const submit = () => {
|
|
155
|
+
if (busy || password === "") return;
|
|
156
|
+
setEdited(false);
|
|
157
|
+
run(onSubmit(password), () => {
|
|
158
|
+
confirmed.current = true;
|
|
159
|
+
setDone(true);
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// A rejection leaves focus where it was (the field, or the submit button) — select
|
|
164
|
+
// the password there so the retry replaces it rather than appending to it.
|
|
165
|
+
const wasPending = useRef(false);
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (wasPending.current && !pending && !done) inputRef.current?.select();
|
|
168
|
+
wasPending.current = pending;
|
|
169
|
+
}, [pending, done]);
|
|
170
|
+
|
|
171
|
+
const shownError = edited ? undefined : error;
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<DialogFrame
|
|
175
|
+
open={open}
|
|
176
|
+
title={title ?? labels.title}
|
|
177
|
+
description={description ?? labels.description}
|
|
178
|
+
onClose={() => onClose(confirmed.current)}
|
|
179
|
+
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
|
|
180
|
+
if (busy && e.key === "Escape") e.preventDefault();
|
|
181
|
+
}}
|
|
182
|
+
aria-busy={busy || undefined}
|
|
183
|
+
actions={
|
|
184
|
+
<ReauthActions
|
|
185
|
+
formId={formId}
|
|
186
|
+
done={done}
|
|
187
|
+
busy={busy}
|
|
188
|
+
canSubmit={password !== ""}
|
|
189
|
+
danger={tone === "danger"}
|
|
190
|
+
cancelLabel={labels.cancel}
|
|
191
|
+
submitLabel={submitLabel ?? labels.submit}
|
|
192
|
+
/>
|
|
193
|
+
}
|
|
194
|
+
>
|
|
195
|
+
{children}
|
|
196
|
+
<form
|
|
197
|
+
id={formId}
|
|
198
|
+
noValidate
|
|
199
|
+
onSubmit={(e) => {
|
|
200
|
+
e.preventDefault();
|
|
201
|
+
submit();
|
|
202
|
+
}}
|
|
203
|
+
>
|
|
204
|
+
{/* CurrentPasswordInput's field — the password manager fills it with the CURRENT
|
|
205
|
+
password and never offers to generate one; `readOnly`, not `disabled`, while
|
|
206
|
+
busy, because disabling the field that has focus drops focus to <body> —
|
|
207
|
+
plus `Input`'s own `error`, which that helper does not take. The message
|
|
208
|
+
paints the field, sits under it and is its `aria-describedby`; passing the
|
|
209
|
+
prop on every render keeps the field's box steady, so the message going away
|
|
210
|
+
on the first keystroke of a retry no longer remounts the <input> under the
|
|
211
|
+
typist (it did, which is why this used to draw its own message). */}
|
|
212
|
+
<Input
|
|
213
|
+
ref={inputRef}
|
|
214
|
+
type="password"
|
|
215
|
+
autoComplete="current-password"
|
|
216
|
+
label={labels.password}
|
|
217
|
+
value={password}
|
|
218
|
+
readOnly={busy}
|
|
219
|
+
error={shownError}
|
|
220
|
+
onChange={(e) => {
|
|
221
|
+
setPassword(e.target.value);
|
|
222
|
+
setEdited(true);
|
|
223
|
+
}}
|
|
224
|
+
/>
|
|
225
|
+
</form>
|
|
226
|
+
</DialogFrame>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The actions row. A component of its own because it renders INSIDE the `Modal`, where
|
|
232
|
+
* the panel's animated close is in context: a resolved `onSubmit` lowers the panel
|
|
233
|
+
* through it, the way Cancel and Escape do, and `Modal` then calls `onClose`.
|
|
234
|
+
*/
|
|
235
|
+
function ReauthActions({
|
|
236
|
+
formId,
|
|
237
|
+
done,
|
|
238
|
+
busy,
|
|
239
|
+
canSubmit,
|
|
240
|
+
danger,
|
|
241
|
+
cancelLabel,
|
|
242
|
+
submitLabel,
|
|
243
|
+
}: {
|
|
244
|
+
formId: string;
|
|
245
|
+
done: boolean;
|
|
246
|
+
busy: boolean;
|
|
247
|
+
canSubmit: boolean;
|
|
248
|
+
danger: boolean;
|
|
249
|
+
cancelLabel: ReactNode;
|
|
250
|
+
submitLabel: ReactNode;
|
|
251
|
+
}) {
|
|
252
|
+
const close = useContext(ModalCloseContext);
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (done) close?.();
|
|
255
|
+
}, [done, close]);
|
|
256
|
+
return (
|
|
257
|
+
<>
|
|
258
|
+
<Button type="button" variant="ghost" disabled={busy} onClick={() => close?.()}>
|
|
259
|
+
{cancelLabel}
|
|
260
|
+
</Button>
|
|
261
|
+
<Button
|
|
262
|
+
type="submit"
|
|
263
|
+
form={formId}
|
|
264
|
+
variant={danger ? "danger" : "primary"}
|
|
265
|
+
disabled={!canSubmit || busy}
|
|
266
|
+
aria-busy={busy || undefined}
|
|
267
|
+
>
|
|
268
|
+
{busy && <Spinner label={null} className="h-4 w-4" />}
|
|
269
|
+
{submitLabel}
|
|
270
|
+
</Button>
|
|
271
|
+
</>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import type { CSSProperties } from "react";
|
|
3
|
+
|
|
4
|
+
import { useKitLabels } from "../i18n/kit-labels";
|
|
5
|
+
import { cn } from "../lib/cn";
|
|
6
|
+
import { serverWake } from "../lib/server-wake";
|
|
7
|
+
import type { ServerWakeStage, ServerWakeWatcher } from "../lib/server-wake";
|
|
8
|
+
import { AlertBanner } from "./alert-banner";
|
|
9
|
+
import { Spinner } from "./ui";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The visible half of the cold-start watchdog (`lib/server-wake.ts`), keksdose's
|
|
13
|
+
* `app/server-wake-notice.tsx` lifted into the kit: after a couple of seconds of an
|
|
14
|
+
* unanswered read it says the app is still loading, and after seven it says WHY — the
|
|
15
|
+
* server sleeps when nobody is using it, and the first request has to start it again.
|
|
16
|
+
*
|
|
17
|
+
* Corner-anchored and non-blocking on purpose. The pages underneath already show their
|
|
18
|
+
* own skeletons; what was missing is the explanation, not another overlay. Start
|
|
19
|
+
* corner, above the phone's bottom nav, so it collides with neither the centred toasts
|
|
20
|
+
* nor the nav bar.
|
|
21
|
+
*
|
|
22
|
+
* Mount it next to the router, not inside the app layout, so it also covers the login
|
|
23
|
+
* screen and the landing page — a cold start is at its most confusing exactly there,
|
|
24
|
+
* before anything of the app is on screen.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// ── Labels ────────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export interface ServerWakeLabels {
|
|
30
|
+
/** Past `slowMs` (2 s): a warm server would have answered by now. */
|
|
31
|
+
slow: string;
|
|
32
|
+
/** Past `wakingMs` (7 s): the explanation. `appName` is the notice's prop; without
|
|
33
|
+
* one the sentence says "the app". */
|
|
34
|
+
waking: (appName?: string) => string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_SERVER_WAKE_LABELS: ServerWakeLabels = {
|
|
38
|
+
slow: "Still loading — this is taking longer than usual.",
|
|
39
|
+
waking: (appName) =>
|
|
40
|
+
`The server goes to sleep when nobody is using ${appName ?? "the app"}, so the first request after a break has to start it again. That can take a moment — nothing is lost, the page fills in by itself.`,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// ── Hook ──────────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/** The watcher's current stage, re-rendering on every change. Default: the shared
|
|
46
|
+
* {@link serverWake} instance. */
|
|
47
|
+
export function useServerWakeStage(watcher: ServerWakeWatcher = serverWake): ServerWakeStage {
|
|
48
|
+
// The server snapshot is `idle`: nothing is in flight while the HTML is rendered.
|
|
49
|
+
return useSyncExternalStore(watcher.subscribe, watcher.getStage, () => "idle");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Notice ────────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export interface ServerWakeNoticeProps {
|
|
55
|
+
/** Default: the shared {@link serverWake} instance. Pass the app's own when it was
|
|
56
|
+
* made with {@link createServerWake} (e.g. to watch the login POST too). */
|
|
57
|
+
watcher?: ServerWakeWatcher;
|
|
58
|
+
/** Named in the `waking` sentence ("…when nobody is using Keksdose…"). Default: the
|
|
59
|
+
* label says "the app". */
|
|
60
|
+
appName?: string;
|
|
61
|
+
labels?: Partial<ServerWakeLabels>;
|
|
62
|
+
/**
|
|
63
|
+
* The height to keep clear at the bottom — AppShell's bottom nav. Default: its
|
|
64
|
+
* measured `--app-nav-h` (0px from `md` up and on pages without a shell, such as the
|
|
65
|
+
* login screen), so nothing has to be passed under an AppShell. The safe area is
|
|
66
|
+
* respected either way, and a 1rem gap is added. Numbers are px.
|
|
67
|
+
*/
|
|
68
|
+
navOffset?: string | number;
|
|
69
|
+
/** Extra classes on the fixed anchor — a different width or `start-*`. The bottom
|
|
70
|
+
* offset is an inline style, so move it vertically with {@link navOffset}. */
|
|
71
|
+
className?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const px = (v: string | number) => (typeof v === "number" ? `${v}px` : v);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Renders nothing visible while idle. The LIVE REGION, though, is always mounted: a
|
|
78
|
+
* `role="status"` element inserted together with its text is announced unreliably
|
|
79
|
+
* (several screen readers only speak changes to a region they already knew about),
|
|
80
|
+
* so the region waits empty and the banner appears inside it. keksdose's notice
|
|
81
|
+
* mounted the region and the words at once.
|
|
82
|
+
*/
|
|
83
|
+
export function ServerWakeNotice({ watcher, appName, labels: labelsProp, navOffset, className }: ServerWakeNoticeProps) {
|
|
84
|
+
const labels = useKitLabels("serverWake", DEFAULT_SERVER_WAKE_LABELS, labelsProp);
|
|
85
|
+
const stage = useServerWakeStage(watcher);
|
|
86
|
+
const waking = stage === "waking";
|
|
87
|
+
const style: CSSProperties = {
|
|
88
|
+
bottom: `calc(max(${navOffset === undefined ? "var(--app-nav-h, 0px)" : px(navOffset)}, env(safe-area-inset-bottom, 0px)) + 1rem)`,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div
|
|
93
|
+
role="status"
|
|
94
|
+
aria-live="polite"
|
|
95
|
+
data-stage={stage}
|
|
96
|
+
style={style}
|
|
97
|
+
// `pointer-events-none` on the empty anchor: a fixed box, even an empty one, is not
|
|
98
|
+
// allowed to swallow a tap on what it sits over.
|
|
99
|
+
className={cn(
|
|
100
|
+
"fixed start-4 z-50 max-w-[min(24rem,calc(100vw-2rem))]",
|
|
101
|
+
stage === "idle" && "pointer-events-none",
|
|
102
|
+
className,
|
|
103
|
+
)}
|
|
104
|
+
>
|
|
105
|
+
{stage !== "idle" && (
|
|
106
|
+
// `elevated`: OPAQUE in both themes (keksdose live #209) — the notice that
|
|
107
|
+
// explains a stall is read over whatever stalled, and a translucent dark tint
|
|
108
|
+
// let the page's own text show through it. A box banner is role-less, so the
|
|
109
|
+
// wrapper above stays the one live region.
|
|
110
|
+
<AlertBanner
|
|
111
|
+
tone={waking ? "warning" : "neutral"}
|
|
112
|
+
size="sm"
|
|
113
|
+
elevated
|
|
114
|
+
// `label={null}`: a Spinner is its own `role="status"` saying "Loading…" —
|
|
115
|
+
// nested here, a second live region and a second announcement ahead of the
|
|
116
|
+
// words that actually explain the wait. Here it is decoration.
|
|
117
|
+
icon={<Spinner label={null} className="h-3.5 w-3.5" />}
|
|
118
|
+
>
|
|
119
|
+
{waking ? labels.waking(appName) : labels.slow}
|
|
120
|
+
</AlertBanner>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|