@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
@@ -154,26 +154,27 @@ const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
154
154
  * digit grouping — "1.000" would read as a thousand in German and as one in English,
155
155
  * and a field that parses its own display back must not have that ambiguity.
156
156
  */
157
- export function NumberField({
158
- value,
159
- onCommit,
160
- onValueChange,
161
- digits,
162
- min,
163
- max,
164
- nullable = false,
165
- unit,
166
- unitPlacement = "suffix",
167
- locale: localeProp,
168
- label,
169
- invalid,
170
- error,
171
- id,
172
- "aria-describedby": ariaDescribedBy,
173
- "aria-invalid": ariaInvalid,
174
- "aria-required": ariaRequired,
175
- ...rest
176
- }: NumberFieldProps) {
157
+ export function NumberField(props: NumberFieldProps) {
158
+ const {
159
+ value,
160
+ onCommit,
161
+ onValueChange,
162
+ digits,
163
+ min,
164
+ max,
165
+ nullable = false,
166
+ unit,
167
+ unitPlacement = "suffix",
168
+ locale: localeProp,
169
+ label,
170
+ invalid,
171
+ error,
172
+ id,
173
+ "aria-describedby": ariaDescribedBy,
174
+ "aria-invalid": ariaInvalid,
175
+ "aria-required": ariaRequired,
176
+ ...rest
177
+ } = props;
177
178
  const locale = useKitLocale(localeProp);
178
179
  const mark = decimalMark(locale);
179
180
  const localize = (text: string) => (mark === "," ? text.replace(/\./g, ",") : text);
@@ -292,15 +293,20 @@ export function NumberField({
292
293
  invalid={invalid || hasError || ariaInvalid === true || ariaInvalid === "true"}
293
294
  />
294
295
  );
295
- // Same shape as Input's FieldGroup: no wrapper at all without a message, so adding
296
- // `error` support changed nothing about the DOM a flex row lays out.
297
- if (!hasError) return field;
296
+ // Input's FieldGroup, restated: no wrapper for a field that never takes `error`, so
297
+ // the prop changed nothing about the DOM a flex row lays out — and a STEADY one for a
298
+ // field that does, message or not. Wrapping only while a message showed swapped the
299
+ // element above the <input>, which remounted it: focus, caret and the half-typed
300
+ // draft's keystrokes were lost the moment the message came or went.
301
+ if (!hasError && !("error" in props)) return field;
298
302
  return (
299
303
  <div>
300
304
  {field}
301
- <p id={errorId} className={FIELD_ERROR_CLASS}>
302
- {error}
303
- </p>
305
+ {hasError && (
306
+ <p id={errorId} className={FIELD_ERROR_CLASS}>
307
+ {error}
308
+ </p>
309
+ )}
304
310
  </div>
305
311
  );
306
312
  }
@@ -42,6 +42,32 @@ export interface PasskeysSettingProps<Id extends PasskeyId = PasskeyId> extends
42
42
  * the user cancelling the OS prompt) keeps what was typed.
43
43
  */
44
44
  onAdd: (name: string) => void | Promise<unknown>;
45
+ /**
46
+ * A step the app runs BEFORE `onAdd` (0.18.0) — re-asking for the current password,
47
+ * a policy notice, anything that may stop the add. Called with the same trimmed name;
48
+ * resolve `true` to go on to `onAdd`, `false` to stop quietly (nothing is said, the
49
+ * typed name stays). A rejection, or a throw, stops the same way: the app reports
50
+ * its own errors. While it runs the add button is disabled and busy, so a second
51
+ * click cannot start a second check.
52
+ *
53
+ * Kurvenschmiede asked for the password in its own dialog by handing `onAdd` a
54
+ * promise it kept open across that dialog (passkeys-card.tsx) — which still works.
55
+ * This is the documented place for it: open the dialog here, resolve with whether it
56
+ * was confirmed, and keep what it produced (the server's registration options) for
57
+ * `onAdd` in a ref. The ceremony then starts from the dialog's click rather than the
58
+ * card's, which is the user activation `navigator.credentials.create` wants.
59
+ *
60
+ * Left out, `onAdd` is called synchronously in the click, exactly as before.
61
+ */
62
+ beforeAdd?: (name: string) => boolean | Promise<boolean>;
63
+ /**
64
+ * What a passkey does to the password, which picks the line under the title (0.18.0).
65
+ * `"instead"` (default): it replaces it — `labels.description`, kastlan's promise.
66
+ * `"alongside"`: the password stays and the passkey is a second way in —
67
+ * `labels.descriptionAlongside`. Kurvenschmiede overrode `description` for exactly
68
+ * this; an app that sets `mode` needs no override.
69
+ */
70
+ mode?: "instead" | "alongside";
45
71
  /** The ceremony is running. The add button says so and does not start a second one. */
46
72
  adding?: boolean;
47
73
  /** Offer "Rename" on each row. Return a promise to leave edit mode only on success. */
@@ -92,6 +118,8 @@ export function PasskeysSetting<Id extends PasskeyId = PasskeyId>({
92
118
  passkeys,
93
119
  loading,
94
120
  onAdd,
121
+ beforeAdd,
122
+ mode = "instead",
95
123
  adding = false,
96
124
  onRename,
97
125
  onDelete,
@@ -108,6 +136,10 @@ export function PasskeysSetting<Id extends PasskeyId = PasskeyId>({
108
136
  const labels = useAccountSettingsLabels("passkeys", labelsProp);
109
137
  const locale = useKitLocale(localeProp);
110
138
  const [name, setName] = useState("");
139
+ // `beforeAdd` is running. State for the button, and a ref for the guard: two clicks
140
+ // in one frame both read the state from before either set it.
141
+ const [checking, setChecking] = useState(false);
142
+ const checkingRef = useRef(false);
111
143
 
112
144
  const date = (value: PasskeyItem["createdAt"]): string | null => {
113
145
  if (value === null || value === undefined || value === "") return null;
@@ -117,13 +149,40 @@ export function PasskeysSetting<Id extends PasskeyId = PasskeyId>({
117
149
  };
118
150
 
119
151
  const add = () => {
120
- if (adding) return;
152
+ if (adding || checkingRef.current) return;
121
153
  const typed = name;
122
- Promise.resolve(onAdd(typed.trim()))
123
- // Clear only what was sent: the field stays editable while the OS prompt is up.
124
- .then(() => setName((current) => (current === typed ? "" : current)))
125
- .catch(() => {});
154
+ const value = typed.trim();
155
+ const send = () =>
156
+ Promise.resolve(onAdd(value))
157
+ // Clear only what was sent: the field stays editable while the OS prompt is up.
158
+ .then(() => setName((current) => (current === typed ? "" : current)));
159
+ if (!beforeAdd) {
160
+ send().catch(() => {});
161
+ return;
162
+ }
163
+ const done = () => {
164
+ checkingRef.current = false;
165
+ setChecking(false);
166
+ };
167
+ checkingRef.current = true;
168
+ setChecking(true);
169
+ let gate: Promise<boolean>;
170
+ // Called in the click, not a microtask later, so a step that itself needs the
171
+ // user's activation (a popup, the clipboard) still has it; a throw is a "no".
172
+ try {
173
+ gate = Promise.resolve(beforeAdd(value));
174
+ } catch (error) {
175
+ gate = Promise.reject(error);
176
+ }
177
+ gate
178
+ .then((go) => {
179
+ done();
180
+ // Strictly `true`: an app whose step resolves `undefined` meant nothing.
181
+ if (go === true) return send();
182
+ })
183
+ .catch(done);
126
184
  };
185
+ const busy = adding || checking;
127
186
 
128
187
  const isLoading = loading || passkeys === undefined;
129
188
  const items = passkeys ?? [];
@@ -171,7 +230,7 @@ export function PasskeysSetting<Id extends PasskeyId = PasskeyId>({
171
230
  }}
172
231
  />
173
232
  )}
174
- <Button onClick={add} disabled={adding} aria-busy={adding || undefined}>
233
+ <Button onClick={add} disabled={busy} aria-busy={busy || undefined}>
175
234
  <KeyRound className="size-4" aria-hidden />
176
235
  {adding ? labels.adding : labels.add}
177
236
  </Button>
@@ -186,7 +245,9 @@ export function PasskeysSetting<Id extends PasskeyId = PasskeyId>({
186
245
  <div>
187
246
  <div className="text-sm font-medium">{labels.title}</div>
188
247
  <div className="text-xs text-[var(--text-muted)]">
189
- {labels.description}
248
+ {mode === "alongside"
249
+ ? labels.descriptionAlongside
250
+ : labels.description}
190
251
  </div>
191
252
  </div>
192
253
  {body}
@@ -2,6 +2,8 @@ import { useId } from "react";
2
2
  import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
3
  import { cn } from "../lib/cn";
4
4
  import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLocale } from "../i18n/kit-labels";
5
+ import { toneTextClass } from "./signed-amount";
6
+ import type { TextTone } from "./signed-amount";
5
7
 
6
8
  /** The words a progress bar adds of its own. */
7
9
  export interface ProgressBarLabels {
@@ -169,6 +171,18 @@ export interface ProgressBarProps extends Omit<ComponentPropsWithoutRef<"div">,
169
171
  * `legendValue={(seg, share) => <>{share} <Amount … /></>}`.
170
172
  */
171
173
  legendValue?: (segment: ProgressBarSegment, formatted: string, index: number) => ReactNode;
174
+ /**
175
+ * The colour of each legend row's figure, as a {@link TextTone} — `toneTextClass`'s
176
+ * palette, the money pair included. Default `"muted"`: the legend explains the bar and
177
+ * should not outshout the headline above it. One tone for every row, or a function
178
+ * per row. A tone and not the segment's own `tone`: keksdose's tax card fills its
179
+ * three parts with one bucket's certainty ramp (`className`), while the FIGURES carry
180
+ * the bucket's money direction — `legendTone="expense"` for the tax owed — which it
181
+ * could only say before through `legendValue` and an app-side `Amount`. A function
182
+ * returning `undefined` keeps that row muted. Applies to `legendValue`'s node too
183
+ * (it inherits the colour unless it sets its own).
184
+ */
185
+ legendTone?: TextTone | ((segment: ProgressBarSegment, index: number) => TextTone | undefined);
172
186
  /** A muted line under the bar — the plan's reset date, "3 of 5 seats in use". It
173
187
  * describes the bar (`aria-describedby`). */
174
188
  hint?: ReactNode;
@@ -224,6 +238,7 @@ export function ProgressBar({
224
238
  segments,
225
239
  legend = false,
226
240
  legendValue,
241
+ legendTone = "muted",
227
242
  hint,
228
243
  overage,
229
244
  sensitive = false,
@@ -441,7 +456,12 @@ export function ProgressBar({
441
456
  <span
442
457
  data-part="legend-value"
443
458
  data-private={priv}
444
- className="ms-auto shrink-0 text-xs tabular-nums text-[var(--text-muted)]"
459
+ className={cn(
460
+ "ms-auto shrink-0 text-xs tabular-nums",
461
+ toneTextClass(
462
+ (typeof legendTone === "function" ? legendTone(seg, i) : legendTone) ?? "muted",
463
+ ),
464
+ )}
445
465
  >
446
466
  {legendValue ? legendValue(seg, formatPart(seg.value), i) : formatPart(seg.value)}
447
467
  </span>
@@ -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,156 @@
1
+ import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
2
+ import type { CSSProperties, RefObject } from "react";
3
+
4
+ import { useKitLabels } from "../i18n/kit-labels";
5
+ import { cn } from "../lib/cn";
6
+ import { clearanceAbove, subscribeFloating } from "../lib/floating-stack";
7
+ import { serverWake } from "../lib/server-wake";
8
+ import type { ServerWakeStage, ServerWakeWatcher } from "../lib/server-wake";
9
+ import { AlertBanner } from "./alert-banner";
10
+ import { Spinner } from "./ui";
11
+
12
+ /**
13
+ * The visible half of the cold-start watchdog (`lib/server-wake.ts`), keksdose's
14
+ * `app/server-wake-notice.tsx` lifted into the kit: after a couple of seconds of an
15
+ * unanswered read it says the app is still loading, and after seven it says WHY — the
16
+ * server sleeps when nobody is using it, and the first request has to start it again.
17
+ *
18
+ * Corner-anchored and non-blocking on purpose. The pages underneath already show their
19
+ * own skeletons; what was missing is the explanation, not another overlay. Start
20
+ * corner, above the phone's bottom nav, so it collides with neither the centred toasts
21
+ * nor the nav bar — and above any FloatingActionButton it would otherwise cover
22
+ * (kastlan's offline pill shares the corner; on a phone the notice spans both).
23
+ *
24
+ * Mount it next to the router, not inside the app layout, so it also covers the login
25
+ * screen and the landing page — a cold start is at its most confusing exactly there,
26
+ * before anything of the app is on screen.
27
+ */
28
+
29
+ // ── Labels ────────────────────────────────────────────────────────────────────
30
+
31
+ export interface ServerWakeLabels {
32
+ /** Past `slowMs` (2 s): a warm server would have answered by now. */
33
+ slow: string;
34
+ /** Past `wakingMs` (7 s): the explanation. `appName` is the notice's prop; without
35
+ * one the sentence says "the app". */
36
+ waking: (appName?: string) => string;
37
+ }
38
+
39
+ export const DEFAULT_SERVER_WAKE_LABELS: ServerWakeLabels = {
40
+ slow: "Still loading — this is taking longer than usual.",
41
+ waking: (appName) =>
42
+ `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.`,
43
+ };
44
+
45
+ // ── Hook ──────────────────────────────────────────────────────────────────────
46
+
47
+ /** The watcher's current stage, re-rendering on every change. Default: the shared
48
+ * {@link serverWake} instance. */
49
+ export function useServerWakeStage(watcher: ServerWakeWatcher = serverWake): ServerWakeStage {
50
+ // The server snapshot is `idle`: nothing is in flight while the HTML is rendered.
51
+ return useSyncExternalStore(watcher.subscribe, watcher.getStage, () => "idle");
52
+ }
53
+
54
+ // ── Notice ────────────────────────────────────────────────────────────────────
55
+
56
+ export interface ServerWakeNoticeProps {
57
+ /** Default: the shared {@link serverWake} instance. Pass the app's own when it was
58
+ * made with {@link createServerWake} (e.g. to watch the login POST too). */
59
+ watcher?: ServerWakeWatcher;
60
+ /** Named in the `waking` sentence ("…when nobody is using Keksdose…"). Default: the
61
+ * label says "the app". */
62
+ appName?: string;
63
+ labels?: Partial<ServerWakeLabels>;
64
+ /**
65
+ * The height to keep clear at the bottom — AppShell's bottom nav. Default: its
66
+ * measured `--app-nav-h` (0px from `md` up and on pages without a shell, such as the
67
+ * login screen), so nothing has to be passed under an AppShell. The safe area is
68
+ * respected either way, and a 1rem gap is added. Numbers are px.
69
+ */
70
+ navOffset?: string | number;
71
+ /** Extra classes on the fixed anchor — a different width or `start-*`. The bottom
72
+ * offset is an inline style, so move it vertically with {@link navOffset}. Either
73
+ * way it rises above a visible FloatingActionButton under it. */
74
+ className?: string;
75
+ }
76
+
77
+ const px = (v: string | number) => (typeof v === "number" ? `${v}px` : v);
78
+
79
+ /** Gap between the notice and a floating control it stacks above. */
80
+ const STACK_GAP = "0.5rem";
81
+
82
+ /**
83
+ * How far off the bottom the notice must sit to clear the FloatingActionButtons under
84
+ * it (`lib/floating-stack.ts`), re-measured when one appears, goes, resizes or the
85
+ * viewport does — or `null` while it is idle or nothing is under it. kastlan 0.18.
86
+ */
87
+ function useFloatingClearance(anchor: RefObject<HTMLDivElement | null>, stage: ServerWakeStage): number | null {
88
+ const [clearance, setClearance] = useState<number | null>(null);
89
+ useLayoutEffect(() => {
90
+ if (stage === "idle") return;
91
+ const measure = () => setClearance(clearanceAbove(anchor.current));
92
+ measure();
93
+ const unsubscribe = subscribeFloating(measure);
94
+ window.addEventListener("resize", measure);
95
+ return () => {
96
+ unsubscribe();
97
+ window.removeEventListener("resize", measure);
98
+ };
99
+ }, [anchor, stage]);
100
+ // Idle, the last measurement is stale and nothing shows anyway.
101
+ return stage === "idle" ? null : clearance;
102
+ }
103
+
104
+ /**
105
+ * Renders nothing visible while idle. The LIVE REGION, though, is always mounted: a
106
+ * `role="status"` element inserted together with its text is announced unreliably
107
+ * (several screen readers only speak changes to a region they already knew about),
108
+ * so the region waits empty and the banner appears inside it. keksdose's notice
109
+ * mounted the region and the words at once.
110
+ */
111
+ export function ServerWakeNotice({ watcher, appName, labels: labelsProp, navOffset, className }: ServerWakeNoticeProps) {
112
+ const labels = useKitLabels("serverWake", DEFAULT_SERVER_WAKE_LABELS, labelsProp);
113
+ const stage = useServerWakeStage(watcher);
114
+ const waking = stage === "waking";
115
+ const anchor = useRef<HTMLDivElement>(null);
116
+ const clearance = useFloatingClearance(anchor, stage);
117
+ const base = `calc(max(${navOffset === undefined ? "var(--app-nav-h, 0px)" : px(navOffset)}, env(safe-area-inset-bottom, 0px)) + 1rem)`;
118
+ const style: CSSProperties = {
119
+ bottom: clearance === null ? base : `max(${base}, calc(${clearance}px + ${STACK_GAP}))`,
120
+ };
121
+
122
+ return (
123
+ <div
124
+ ref={anchor}
125
+ role="status"
126
+ aria-live="polite"
127
+ data-stage={stage}
128
+ style={style}
129
+ // `pointer-events-none` on the empty anchor: a fixed box, even an empty one, is not
130
+ // allowed to swallow a tap on what it sits over.
131
+ className={cn(
132
+ "fixed start-4 z-50 max-w-[min(24rem,calc(100vw-2rem))]",
133
+ stage === "idle" && "pointer-events-none",
134
+ className,
135
+ )}
136
+ >
137
+ {stage !== "idle" && (
138
+ // `elevated`: OPAQUE in both themes (keksdose live #209) — the notice that
139
+ // explains a stall is read over whatever stalled, and a translucent dark tint
140
+ // let the page's own text show through it. A box banner is role-less, so the
141
+ // wrapper above stays the one live region.
142
+ <AlertBanner
143
+ tone={waking ? "warning" : "neutral"}
144
+ size="sm"
145
+ elevated
146
+ // `label={null}`: a Spinner is its own `role="status"` saying "Loading…" —
147
+ // nested here, a second live region and a second announcement ahead of the
148
+ // words that actually explain the wait. Here it is decoration.
149
+ icon={<Spinner label={null} className="h-3.5 w-3.5" />}
150
+ >
151
+ {waking ? labels.waking(appName) : labels.slow}
152
+ </AlertBanner>
153
+ )}
154
+ </div>
155
+ );
156
+ }