@eifi1/ui-kit 0.22.0 → 0.24.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 (208) hide show
  1. package/README.md +42 -25
  2. package/dist/chart.d.ts +8 -7
  3. package/dist/components/amount-input.d.ts +34 -7
  4. package/dist/components/amount-input.js +14 -10
  5. package/dist/components/amount-input.js.map +1 -1
  6. package/dist/components/autocomplete.d.ts +26 -0
  7. package/dist/components/autocomplete.js +164 -137
  8. package/dist/components/autocomplete.js.map +1 -1
  9. package/dist/components/button-group.d.ts +8 -7
  10. package/dist/components/calculator.d.ts +8 -7
  11. package/dist/components/column-mapper.d.ts +235 -0
  12. package/dist/components/column-mapper.js +411 -0
  13. package/dist/components/column-mapper.js.map +1 -0
  14. package/dist/components/combobox-core.d.ts +39 -2
  15. package/dist/components/combobox-core.js +29 -6
  16. package/dist/components/combobox-core.js.map +1 -1
  17. package/dist/components/combobox.d.ts +113 -3
  18. package/dist/components/combobox.js +456 -313
  19. package/dist/components/combobox.js.map +1 -1
  20. package/dist/components/confirm-dialog.d.ts +8 -7
  21. package/dist/components/confirm-dialog.js +7 -8
  22. package/dist/components/confirm-dialog.js.map +1 -1
  23. package/dist/components/copy-button.d.ts +5 -4
  24. package/dist/components/country-select.d.ts +50 -7
  25. package/dist/components/country-select.js +65 -9
  26. package/dist/components/country-select.js.map +1 -1
  27. package/dist/components/danger-confirm.d.ts +8 -7
  28. package/dist/components/danger-confirm.js +64 -7
  29. package/dist/components/danger-confirm.js.map +1 -1
  30. package/dist/components/date-picker.d.ts +41 -0
  31. package/dist/components/date-picker.js +269 -8
  32. package/dist/components/date-picker.js.map +1 -1
  33. package/dist/components/entity-combobox.d.ts +48 -2
  34. package/dist/components/entity-combobox.js +159 -114
  35. package/dist/components/entity-combobox.js.map +1 -1
  36. package/dist/components/facing-pair.d.ts +8 -7
  37. package/dist/components/field-parts.d.ts +116 -6
  38. package/dist/components/field-parts.js +84 -0
  39. package/dist/components/field-parts.js.map +1 -1
  40. package/dist/components/field-strip.d.ts +105 -0
  41. package/dist/components/field-strip.js +46 -0
  42. package/dist/components/field-strip.js.map +1 -0
  43. package/dist/components/file-button.d.ts +8 -7
  44. package/dist/components/file-dropzone.d.ts +8 -7
  45. package/dist/components/form-actions.d.ts +8 -7
  46. package/dist/components/form-actions.js +23 -3
  47. package/dist/components/form-actions.js.map +1 -1
  48. package/dist/components/iban-input.d.ts +8 -7
  49. package/dist/components/icon-picker.d.ts +10 -1
  50. package/dist/components/icon-picker.js +12 -4
  51. package/dist/components/icon-picker.js.map +1 -1
  52. package/dist/components/language-select.d.ts +8 -7
  53. package/dist/components/money-field.d.ts +11 -7
  54. package/dist/components/money-field.js.map +1 -1
  55. package/dist/components/month-picker.js +2 -1
  56. package/dist/components/month-picker.js.map +1 -1
  57. package/dist/components/multi-entity-combobox.d.ts +31 -2
  58. package/dist/components/multi-entity-combobox.js +152 -103
  59. package/dist/components/multi-entity-combobox.js.map +1 -1
  60. package/dist/components/number-field.d.ts +8 -7
  61. package/dist/components/number-input.d.ts +8 -7
  62. package/dist/components/numpad-sheet.d.ts +8 -7
  63. package/dist/components/phone-input.d.ts +8 -7
  64. package/dist/components/reauth-dialog.d.ts +2 -2
  65. package/dist/components/reauth-dialog.js +6 -8
  66. package/dist/components/reauth-dialog.js.map +1 -1
  67. package/dist/components/series-chart.d.ts +8 -7
  68. package/dist/components/settings-fields.d.ts +8 -7
  69. package/dist/components/share-card.d.ts +8 -7
  70. package/dist/components/swatch-picker.d.ts +17 -1
  71. package/dist/components/swatch-picker.js +10 -4
  72. package/dist/components/swatch-picker.js.map +1 -1
  73. package/dist/components/text-link.d.ts +8 -7
  74. package/dist/components/tile-radio.d.ts +20 -0
  75. package/dist/components/tile-radio.js +25 -8
  76. package/dist/components/tile-radio.js.map +1 -1
  77. package/dist/components/time-input.d.ts +8 -7
  78. package/dist/components/toggle-group.js +2 -2
  79. package/dist/components/toggle-group.js.map +1 -1
  80. package/dist/components/trigger-aria.d.ts +3 -0
  81. package/dist/components/trigger-aria.js +3 -1
  82. package/dist/components/trigger-aria.js.map +1 -1
  83. package/dist/components/ui.d.ts +5 -4
  84. package/dist/components/ui.js +60 -28
  85. package/dist/components/ui.js.map +1 -1
  86. package/dist/feedback/feedback-attachment.d.ts +66 -1
  87. package/dist/feedback/feedback-attachment.js +185 -65
  88. package/dist/feedback/feedback-attachment.js.map +1 -1
  89. package/dist/feedback/feedback-dialog.d.ts +66 -1
  90. package/dist/feedback/feedback-dialog.js.map +1 -1
  91. package/dist/feedback/feedback-inbox.d.ts +67 -252
  92. package/dist/feedback/feedback-inbox.js.map +1 -1
  93. package/dist/feedback/feedback-thread.d.ts +66 -287
  94. package/dist/feedback/feedback-thread.js +16 -3
  95. package/dist/feedback/feedback-thread.js.map +1 -1
  96. package/dist/{kit-labels-R8mIAc9N.d.ts → feedback-DOwPu-Il.d.ts} +1273 -30
  97. package/dist/feedback.d.ts +65 -3
  98. package/dist/hooks/use-file-drop.d.ts +8 -7
  99. package/dist/i18n/defaults.d.ts +8 -7
  100. package/dist/i18n/defaults.js +3 -1
  101. package/dist/i18n/defaults.js.map +1 -1
  102. package/dist/i18n/german.d.ts +8 -7
  103. package/dist/i18n/german.js +36 -2
  104. package/dist/i18n/german.js.map +1 -1
  105. package/dist/i18n/kit-labels.d.ts +5 -4
  106. package/dist/i18n/kit-labels.js.map +1 -1
  107. package/dist/i18n/languages.d.ts +8 -7
  108. package/dist/i18n/locales/de-CH.d.ts +8 -7
  109. package/dist/i18n/locales/en.d.ts +8 -7
  110. package/dist/i18n/locales/en.js +7 -0
  111. package/dist/i18n/locales/en.js.map +1 -1
  112. package/dist/i18n/locales/es.d.ts +8 -7
  113. package/dist/i18n/locales/es.js +37 -2
  114. package/dist/i18n/locales/es.js.map +1 -1
  115. package/dist/i18n/locales/fr.d.ts +8 -7
  116. package/dist/i18n/locales/fr.js +36 -2
  117. package/dist/i18n/locales/fr.js.map +1 -1
  118. package/dist/i18n/locales/hu.d.ts +8 -7
  119. package/dist/i18n/locales/hu.js +38 -2
  120. package/dist/i18n/locales/hu.js.map +1 -1
  121. package/dist/i18n/locales/it.d.ts +8 -7
  122. package/dist/i18n/locales/it.js +37 -2
  123. package/dist/i18n/locales/it.js.map +1 -1
  124. package/dist/i18n/locales/zh.d.ts +8 -7
  125. package/dist/i18n/locales/zh.js +34 -2
  126. package/dist/i18n/locales/zh.js.map +1 -1
  127. package/dist/i18n/review.d.ts +8 -7
  128. package/dist/i18n/review.js +20 -1
  129. package/dist/i18n/review.js.map +1 -1
  130. package/dist/index.d.ts +6 -4
  131. package/dist/index.js +16 -0
  132. package/dist/index.js.map +1 -1
  133. package/dist/lib/column-mapping.d.ts +81 -0
  134. package/dist/lib/column-mapping.js +108 -0
  135. package/dist/lib/column-mapping.js.map +1 -0
  136. package/dist/lib/strip-fade.d.ts +2 -2
  137. package/dist/lib/strip-fade.js.map +1 -1
  138. package/dist/lib/table-text.d.ts +109 -1
  139. package/dist/lib/table-text.js +122 -1
  140. package/dist/lib/table-text.js.map +1 -1
  141. package/dist/rhf/fields.d.ts +142 -13
  142. package/dist/rhf/fields.js +185 -3
  143. package/dist/rhf/fields.js.map +1 -1
  144. package/dist/rhf/form.d.ts +8 -7
  145. package/dist/rhf.d.ts +9 -8
  146. package/dist/rhf.js.map +1 -1
  147. package/dist/shell/app-shell.d.ts +8 -7
  148. package/dist/shell/top-bar-brand.d.ts +8 -7
  149. package/dist/shell/topbar-action-menu.d.ts +36 -3
  150. package/dist/shell/topbar-action-menu.js +74 -33
  151. package/dist/shell/topbar-action-menu.js.map +1 -1
  152. package/dist/shell.d.ts +8 -7
  153. package/dist/table-text.d.ts +1 -1
  154. package/dist/wizard/stepper-nav.d.ts +38 -9
  155. package/dist/wizard/stepper-nav.js +4 -0
  156. package/dist/wizard/stepper-nav.js.map +1 -1
  157. package/dist/wizard.d.ts +8 -7
  158. package/package.json +1 -1
  159. package/src/components/amount-input.tsx +50 -14
  160. package/src/components/autocomplete.tsx +188 -116
  161. package/src/components/column-mapper.tsx +768 -0
  162. package/src/components/combobox-core.tsx +82 -9
  163. package/src/components/combobox.tsx +655 -335
  164. package/src/components/confirm-dialog.tsx +12 -8
  165. package/src/components/country-select.tsx +135 -22
  166. package/src/components/danger-confirm.tsx +274 -24
  167. package/src/components/date-picker.tsx +423 -10
  168. package/src/components/entity-combobox.tsx +243 -129
  169. package/src/components/field-parts.tsx +224 -5
  170. package/src/components/field-strip.tsx +149 -0
  171. package/src/components/form-actions.tsx +52 -5
  172. package/src/components/icon-picker.tsx +23 -4
  173. package/src/components/money-field.tsx +3 -0
  174. package/src/components/month-picker.tsx +2 -1
  175. package/src/components/multi-entity-combobox.tsx +221 -121
  176. package/src/components/reauth-dialog.tsx +17 -18
  177. package/src/components/swatch-picker.tsx +29 -5
  178. package/src/components/tile-radio.tsx +74 -13
  179. package/src/components/toggle-group.tsx +2 -2
  180. package/src/components/trigger-aria.ts +5 -0
  181. package/src/components/ui.tsx +114 -33
  182. package/src/feedback/feedback-attachment.tsx +372 -72
  183. package/src/feedback/feedback-dialog.tsx +7 -3
  184. package/src/feedback/feedback-inbox.tsx +3 -2
  185. package/src/feedback/feedback-thread.tsx +112 -9
  186. package/src/i18n/defaults.ts +2 -0
  187. package/src/i18n/german.ts +42 -0
  188. package/src/i18n/kit-labels.tsx +4 -0
  189. package/src/i18n/locales/en.ts +27 -5
  190. package/src/i18n/locales/es.ts +41 -0
  191. package/src/i18n/locales/fr.ts +42 -0
  192. package/src/i18n/locales/hu.ts +37 -0
  193. package/src/i18n/locales/it.ts +41 -0
  194. package/src/i18n/locales/zh.ts +32 -0
  195. package/src/i18n/review.ts +19 -0
  196. package/src/index.ts +18 -0
  197. package/src/lib/column-mapping.ts +234 -0
  198. package/src/lib/strip-fade.ts +2 -2
  199. package/src/lib/table-text.ts +271 -0
  200. package/src/rhf/fields.tsx +421 -6
  201. package/src/rhf.ts +2 -1
  202. package/src/shell/topbar-action-menu.tsx +134 -38
  203. package/src/wizard/stepper-nav.tsx +35 -1
  204. package/dist/components/field-anatomy.d.ts +0 -95
  205. package/dist/components/field-anatomy.js +0 -84
  206. package/dist/components/field-anatomy.js.map +0 -1
  207. package/dist/feedback-attachment-NhUm7Zga.d.ts +0 -367
  208. package/src/components/field-anatomy.tsx +0 -190
@@ -1,9 +1,9 @@
1
- import { forwardRef, useCallback, useEffect, useId, useRef, useState } from "react";
1
+ import { Children, forwardRef, useCallback, useEffect, useId, useRef, useState } from "react";
2
2
  import type { ChangeEvent, ComponentPropsWithoutRef, FormEvent, ReactNode } 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
- import type { InputProps } from "./ui";
6
+ import type { ButtonVariant, InputProps } from "./ui";
7
7
  import { Checkbox } from "./checkbox";
8
8
  import { Tooltip } from "./tooltip";
9
9
  import { useCommitReason } from "./write-lock";
@@ -324,6 +324,18 @@ export interface DangerConfirmLabels {
324
324
  /** 0.22: the checkbox `requireAcknowledge={true}` shows — "I have read what this does
325
325
  * and want to continue" (keksdose's `admin.users.confirm_ack`). */
326
326
  acknowledge: string;
327
+ /**
328
+ * 0.23: why the armed confirm is held while the typed phrase does not match yet — in
329
+ * the confirm's tooltip and its description (keksdose G4a). A FUNCTION of the phrase
330
+ * by default ("Type “DELETE” to confirm"), or a finished STRING, as `phrase`.
331
+ */
332
+ needsPhrase: string | ((phrase: string) => string);
333
+ /** 0.23: why the armed confirm is held while the `requireAcknowledge` box is unticked
334
+ * — "Tick the box to confirm". */
335
+ needsAcknowledge: string;
336
+ /** 0.23: why the armed confirm is held while the `requirePassword` field is empty —
337
+ * "Enter your password to confirm". */
338
+ needsPassword: string;
327
339
  }
328
340
 
329
341
  /** `satisfies` rather than a type annotation, so `DEFAULT_DANGER_CONFIRM_LABELS.phrase`
@@ -336,8 +348,33 @@ export const DEFAULT_DANGER_CONFIRM_LABELS = {
336
348
  password: "Password",
337
349
  phrase: (phrase: string) => `Type “${phrase}” to confirm`,
338
350
  acknowledge: "I have read what this does and want to continue.",
351
+ needsPhrase: (phrase: string) => `Type “${phrase}” to confirm`,
352
+ needsAcknowledge: "Tick the box to confirm",
353
+ needsPassword: "Enter your password to confirm",
339
354
  } satisfies DangerConfirmLabels;
340
355
 
356
+ /**
357
+ * What the guards were answered with — the second argument of
358
+ * {@link DangerConfirmProps.onConfirm} (0.23, keksdose G4b). A key is there only when its
359
+ * guard was asked for, so the object can go into a request body as it is.
360
+ */
361
+ export interface DangerConfirmValues {
362
+ /**
363
+ * The text typed into the `phrase` field — as the match rule compared it: trimmed
364
+ * under `phraseMatch` `"trim"` and `"caseless"` (the spaces a phone keyboard adds are
365
+ * not part of it), exactly as typed under `"exact"`. The case is the user's, even
366
+ * under `"caseless"`: a server that re-checks applies its own rule to it. Absent
367
+ * without a `phrase`.
368
+ */
369
+ typed?: string;
370
+ /** The entered password — the same value as the first argument. Absent without
371
+ * `requirePassword`. */
372
+ password?: string;
373
+ /** `true` when the `requireAcknowledge` box was shown — it was ticked, since the
374
+ * confirm is held until it is. Absent without one. */
375
+ acknowledged?: true;
376
+ }
377
+
341
378
  /**
342
379
  * One line of {@link DangerConfirmProps.consequences}: what the action will do. A plain
343
380
  * string, or this, for a key of its own and the `severe` mark.
@@ -360,12 +397,25 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
360
397
  * (fields wiped) when it resolves, still armed when it rejects — so the user can
361
398
  * correct a wrong password and retry. The rejection is not swallowed for you to
362
399
  * miss: handle it (and show why) in the caller, as with any mutation.
400
+ *
401
+ * 0.23: the second argument is what every guard was answered with — the typed phrase,
402
+ * the password, the tick ({@link DangerConfirmValues}). keksdose G4b: its admin
403
+ * actions send the address the admin TYPED, and the server re-checks it against the
404
+ * account (`assert_confirmed`), so a client that put the wrong row's id in the request
405
+ * is refused rather than obeyed; the tile kept the text to itself.
406
+ *
407
+ * onConfirm={(_password, { typed }) =>
408
+ * reset.mutateAsync({ id, confirm: { acknowledged: true, confirm_email: typed ?? null } })}
409
+ *
410
+ * A second argument rather than one object in place of the password: every
411
+ * `(password) => …` and `() => …` handler, and a `mutation.mutate` passed as it is,
412
+ * keeps working unchanged, and a handler that needs one guard's answer names it.
363
413
  */
364
- onConfirm: (password?: string) => void | Promise<unknown>;
365
- /** Show a password field; confirm stays disabled until it is filled. */
414
+ onConfirm: (password: string | undefined, values: DangerConfirmValues) => void | Promise<unknown>;
415
+ /** Show a password field; confirm is held until it is filled. */
366
416
  requirePassword?: boolean;
367
417
  /**
368
- * Show an "I understand" checkbox; confirm stays disabled until it is ticked
418
+ * Show an "I understand" checkbox; confirm is held until it is ticked
369
419
  * (keksdose K7). `true` words it with `labels.acknowledge`; any other node IS the
370
420
  * checkbox's label ("I understand Anna will be signed out everywhere").
371
421
  *
@@ -386,8 +436,8 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
386
436
  * who reads the same paragraph on every row stops reading it (keksdose dev#488).
387
437
  */
388
438
  consequences?: readonly (string | DangerConsequence)[];
389
- /** Show a "type <phrase> to confirm" field; confirm stays disabled until the field
390
- * matches (case-sensitive; surrounding spaces ignored unless `phraseMatch="exact"`). */
439
+ /** Show a "type <phrase> to confirm" field; confirm is held until the field matches
440
+ * (case-sensitive; surrounding spaces ignored unless `phraseMatch="exact"`). */
391
441
  phrase?: string;
392
442
  /**
393
443
  * How the typed text is compared with `phrase`. `"trim"` (default) ignores spaces
@@ -401,15 +451,99 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
401
451
  /** The warning above the fields. Defaults to `labels.prompt`. */
402
452
  prompt?: ReactNode;
403
453
  /** `"danger"` (default) for what cannot be undone; `"warning"` for what can, at a
404
- * cost (loading demo data over your own). Colours the prompt and the confirm. */
454
+ * cost (loading demo data over your own). Colours the prompt, and picks the arm
455
+ * button's variant and — unless {@link DangerConfirmProps.confirmVariant} is
456
+ * given — the confirm's. */
405
457
  tone?: "danger" | "warning";
458
+ /**
459
+ * The confirm button's variant, apart from `tone` (0.24, keksdose). Default: what
460
+ * `tone` picks, as before — `"danger"` for `"danger"`, `"primary"` for `"warning"`.
461
+ *
462
+ * `tone` says how bad the outcome is; this says how loud the last press is, and the two
463
+ * need not agree. keksdose's `UserActionConfirm` (an admin resets somebody else's
464
+ * password) asks an AMBER question — what it guards against is mis-targeting, not data
465
+ * loss, so the danger colour stays its one `severe` consequence's — and ends in a RED
466
+ * Go, as its hand-built panel did before it moved onto this tile in 0.23; with only
467
+ * `tone`, the amber question made Go the neutral primary fill. `tone="warning"
468
+ * confirmVariant="danger"` is that panel again.
469
+ */
470
+ confirmVariant?: ButtonVariant;
471
+ /**
472
+ * Hold the armed confirm for a reason of the CALLER's own (0.24, keksdose) — the
473
+ * tile's counterpart of FormActions' `submitDisabledReason`. While it has content the
474
+ * confirm is held exactly as by the built-in guards: `aria-disabled` but focusable,
475
+ * the reason in the kit {@link Tooltip} and its description, a press — or Enter in a
476
+ * field — does nothing. Nothing else changes: the arm button stays usable, and no
477
+ * line is printed under the buttons.
478
+ *
479
+ * keksdose's `UserPlanEditor` ("move this account onto another plan") must not confirm
480
+ * the plan the account already holds — the request would only write FREE → FREE into
481
+ * the audit trail. With no guard of its own to give, it passed "Pick a different plan"
482
+ * as `lockedReason`, which is a LOCK: documented as "the action is not available",
483
+ * spelled out under the buttons as well as in the tooltip, and meant for a write the
484
+ * user cannot make land. A guard the user lifts by changing a field is this prop.
485
+ *
486
+ * Which reason the held confirm names: a write lock's first (`lockedReason`, `commit`
487
+ * — nothing in the tile lifts it), then this one, then the built-in guards in the
488
+ * order they are drawn (the tick, the phrase, the password). This one before the
489
+ * built-ins because it is usually about a field in
490
+ * {@link DangerConfirmProps.children}, which is drawn ABOVE them — the rule since 0.23
491
+ * is "the first open guard in the order the user meets it" — and because it is about
492
+ * WHAT is being confirmed (which plan), which has to be settled before acknowledging
493
+ * it means anything. It is also the order keksdose's plan editor had, its
494
+ * `lockedReason` winning over the tick.
495
+ */
496
+ confirmDisabledReason?: ReactNode;
497
+ /**
498
+ * Fields of the caller's own, drawn INSIDE the armed tile (0.24, keksdose): after the
499
+ * prompt and the consequences, before the tick, the typed phrase and the password —
500
+ * the order is "what this does, what to do it with, then prove you mean it". Nothing
501
+ * is drawn for them while the tile is disarmed, and nothing at all without them, so a
502
+ * tile without children is laid out exactly as before.
503
+ *
504
+ * keksdose's `UserPlanEditor` asks WHICH plan before it asks for the tick; with no
505
+ * place inside the tile for the picker, the `Select` sat above it, and the tile's
506
+ * prompt — the panel's title by rights — became the "FREE → PRO" line under the
507
+ * picker. With the picker here, the prompt is the title again.
508
+ *
509
+ * The fields are the caller's: their values are not wiped on disarm (an uncontrolled
510
+ * field starts over anyway — the slot unmounts), not passed to `onConfirm`, and a
511
+ * guard on them is {@link DangerConfirmProps.confirmDisabledReason}. Arming moves
512
+ * focus to their first form field (an input, a select, a combobox, a radio…), else to
513
+ * the tile's own first field as before; Tab then goes on through the tick, the phrase
514
+ * and the password to Cancel and the confirm. They sit inside the tile's `<form>`:
515
+ * Enter in a text field confirms once every guard allows, and a {@link Button} among
516
+ * them needs `type="button"` — without one it SUBMITS the form, as a native button
517
+ * does.
518
+ */
519
+ children?: ReactNode;
406
520
  /** Visible text of the arm button; defaults to `labels.arm`. */
407
521
  armLabel?: ReactNode;
408
522
  /** Visible text of the confirm button; defaults to `labels.confirm`. */
409
523
  confirmLabel?: ReactNode;
410
- /** The action is running: confirm shows a spinner and nothing can be pressed. For a
411
- * caller that tracks the mutation itself (a `useMutation`'s `isPending`); a
412
- * promise returned from `onConfirm` does the same on its own. */
524
+ /**
525
+ * The action is running: confirm shows a spinner and nothing can be pressed — Cancel
526
+ * included. For a caller that tracks the mutation itself (a `useMutation`'s
527
+ * `isPending`); a promise returned from `onConfirm` does the same on its own.
528
+ *
529
+ * Cancel stays disabled ON PURPOSE, unlike FormActions' (keksdose asked in 0.24 why the
530
+ * two differ). Once the request has left, nothing on the client can call it back —
531
+ * not even an `AbortSignal`, which only stops the waiting, not the server — so a usable
532
+ * Cancel could only collapse the tile while the action went on: "Cancel" pressed, and
533
+ * the account reset anyway. Every way of settling what comes back is wrong somewhere: a
534
+ * resolve after the Cancel did what the user called off; a reject finds the fields
535
+ * wiped (a collapsed tile holds no password), so the 0.23 "stay armed and retry" is
536
+ * gone; and re-arming while the first request runs is either a busy tile with empty
537
+ * fields or — if the busy state went with the Cancel — a second destructive request
538
+ * beside the first. FormActions' Cancel leaves an editor whose save is the user's own
539
+ * edit landing, and a form can be edited again; the tile's action is the one that
540
+ * cannot be undone, so it says nothing it cannot keep and waits — the wait is one
541
+ * request long.
542
+ *
543
+ * When the action settles still armed (it failed) and the press on the confirm had
544
+ * dropped the focus — a natively disabled button loses it — focus comes back to the
545
+ * confirm, so a keyboard user can retry or Shift+Tab to the field to correct (0.24).
546
+ */
413
547
  busy?: boolean;
414
548
  /** The arm button is disabled. */
415
549
  disabled?: boolean;
@@ -443,11 +577,55 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
443
577
  labels?: Partial<DangerConfirmLabels>;
444
578
  }
445
579
 
580
+ /** What counts as a FIELD among the caller's `children` — what arming focuses. Not
581
+ * any tabbable element: a "see the plans" link or a disclosure button before the
582
+ * picker is not where the user starts. `[tabindex="-1"]` is a roving radio group's
583
+ * unselected option; the selected one (`0`) is the group's stop. */
584
+ const SLOT_FIELD = [
585
+ 'input:not([type="hidden"])',
586
+ "select",
587
+ "textarea",
588
+ ...["combobox", "listbox", "radio", "checkbox", "switch", "slider", "spinbutton", "textbox"].map(
589
+ (role) => `[role="${role}"]`,
590
+ ),
591
+ ]
592
+ .map((selector) => `${selector}:not(:disabled):not([tabindex="-1"]):not([aria-hidden="true"])`)
593
+ .join(", ");
594
+
595
+ /** The first field among the caller's `children`, skipping a native radio that is not
596
+ * the checked one of its group (Tab lands on the checked one). */
597
+ function firstSlotField(slot: HTMLElement | null): HTMLElement | null {
598
+ if (!slot) return null;
599
+ for (const el of slot.querySelectorAll<HTMLElement>(SLOT_FIELD)) {
600
+ if (el.closest("[hidden], [inert]")) continue;
601
+ if (el instanceof HTMLInputElement && el.type === "radio" && !el.checked && el.name) {
602
+ const group = el.form?.elements.namedItem(el.name);
603
+ const checked =
604
+ group instanceof RadioNodeList && Array.from(group).some((r) => r instanceof HTMLInputElement && r.checked);
605
+ if (checked) continue;
606
+ }
607
+ return el;
608
+ }
609
+ return null;
610
+ }
611
+
446
612
  /**
447
613
  * An "arm → confirm" tile for destructive actions: one button, which expands into a
448
- * warning, an optional list of consequences, an optional "I understand" tick, an
449
- * optional type-to-confirm field, an optional password field and a confirm that stays
450
- * disabled until every guard is satisfied.
614
+ * warning, an optional list of consequences, optional fields of the caller's own
615
+ * (`children`, 0.24), an optional "I understand" tick, an optional type-to-confirm
616
+ * field, an optional password field and a confirm that is held until every guard is
617
+ * satisfied — the built-in ones, and the caller's `confirmDisabledReason` (0.24).
618
+ *
619
+ * A held confirm SAYS which guard is still open (0.23, keksdose G4a): it is
620
+ * `aria-disabled` rather than `disabled` — still focusable, so a keyboard user can land
621
+ * on it — with the reason in the kit {@link Tooltip} and its description ("Type
622
+ * “DELETE” to confirm", "Tick the box to confirm", "Enter your password to confirm";
623
+ * `labels.needs*`), the first open guard in the order they are drawn. FormActions'
624
+ * `submitDisabledReason` does the same for a form's Save. Pressing it, or Enter in a
625
+ * field, does nothing. A lock's reason wins over a guard's, the caller's guard over the
626
+ * built-in ones; while the action runs the confirm is plainly disabled, its spinner
627
+ * saying why — and so is Cancel, which cannot call back a request that has left (see
628
+ * `busy`).
451
629
  *
452
630
  * Keksdose hand-rolled it three times (load demo data, wipe everything, reset a
453
631
  * budget) and then as `shared/components/danger-confirm.tsx`; the only app-specific
@@ -468,6 +646,9 @@ export function DangerConfirm({
468
646
  phraseMatch = "trim",
469
647
  prompt,
470
648
  tone = "danger",
649
+ confirmVariant,
650
+ confirmDisabledReason,
651
+ children,
471
652
  armLabel,
472
653
  confirmLabel,
473
654
  busy: busyProp,
@@ -495,13 +676,18 @@ export function DangerConfirm({
495
676
  const consequencesId = useId();
496
677
  const reasonId = useId();
497
678
 
498
- // The kit's Button takes no ref, so the two buttons focus is moved to are found by id.
679
+ // The buttons focus is moved to are found by id.
499
680
  const armId = useId();
500
681
  const cancelId = useId();
682
+ const confirmId = useId();
501
683
  const firstFieldRef = useRef<HTMLInputElement>(null);
684
+ // The caller's fields (`children`), searched for their first form field on arm.
685
+ const slotRef = useRef<HTMLDivElement>(null);
502
686
  // Focus moves only after a transition — never on mount, so a tile that renders armed
503
687
  // (controlled) does not take the page's focus merely by existing.
504
688
  const moveFocus = useRef(false);
689
+ // A confirm from this tile is under way — see the focus return after a failure below.
690
+ const confirmed = useRef(false);
505
691
 
506
692
  // A disarm from anywhere (cancel, a resolved confirm, the parent) wipes the fields.
507
693
  // During render, like NumberField's draft, so no frame shows a collapsed tile that
@@ -524,15 +710,38 @@ export function DangerConfirm({
524
710
  moveFocus.current = false;
525
711
  if (!changed) return;
526
712
  if (armed) {
527
- if (byUser) (firstFieldRef.current ?? document.getElementById(cancelId))?.focus();
713
+ if (byUser) {
714
+ // The caller's fields come first on the page, so first in focus too.
715
+ (firstSlotField(slotRef.current) ?? firstFieldRef.current ?? document.getElementById(cancelId))?.focus();
716
+ }
528
717
  return;
529
718
  }
719
+ confirmed.current = false;
530
720
  // A controlled parent collapsing the tile from its own `onSuccess` did not go
531
721
  // through `setArmed`; if focus went down with the form, bring it back too.
532
722
  const lost = document.activeElement === null || document.activeElement === document.body;
533
723
  if (byUser || lost) document.getElementById(armId)?.focus();
534
724
  }, [armed, armId, cancelId]);
535
725
 
726
+ // The action settled and the tile is still armed — it failed, and the user retries.
727
+ // The confirm was natively disabled while it ran, and a focused button that turns
728
+ // disabled drops the focus to <body>: bring it back to the button that was pressed.
729
+ // Only after a confirm from THIS tile (`confirmed`), so a `busy` that comes and goes
730
+ // on its own never pulls the page's focus in. (A resolve disarms, and the effect
731
+ // above takes the focus to the arm button.)
732
+ const wasBusy = useRef(busy);
733
+ useEffect(() => {
734
+ const settled = wasBusy.current && !busy;
735
+ wasBusy.current = busy;
736
+ if (!settled) return;
737
+ const ours = confirmed.current;
738
+ confirmed.current = false;
739
+ if (!ours || !armed) return;
740
+ if (document.activeElement === null || document.activeElement === document.body) {
741
+ document.getElementById(confirmId)?.focus();
742
+ }
743
+ }, [busy, armed, confirmId]);
744
+
536
745
  const setArmed = (next: boolean) => {
537
746
  moveFocus.current = true;
538
747
  if (armedProp === undefined) setArmedState(next);
@@ -548,15 +757,46 @@ export function DangerConfirm({
548
757
  const passwordOk = !requirePassword || password !== "";
549
758
  const phraseOk = phrase === undefined || typedMatches(typed, phrase, phraseMatch);
550
759
  const acknowledgeOk = !asksAcknowledge || acknowledged;
551
- const canConfirm = passwordOk && phraseOk && acknowledgeOk && !busy;
760
+ const callerHeld = hasContent(confirmDisabledReason);
761
+ // `{cond && <Select />}` and `[null, false]` are no fields: no wrapper, no gap.
762
+ const hasFields = Children.toArray(children).some(hasContent);
763
+ const canConfirm = passwordOk && phraseOk && acknowledgeOk && !callerHeld && !busy;
764
+ // The first guard still open, in the order the fields are drawn — the one the user
765
+ // meets next, so the sentence points at it.
766
+ const guardReason: string | undefined = !acknowledgeOk
767
+ ? labels.needsAcknowledge
768
+ : !phraseOk
769
+ ? typeof labels.needsPhrase === "function"
770
+ ? labels.needsPhrase(phrase ?? "")
771
+ : labels.needsPhrase
772
+ : !passwordOk
773
+ ? labels.needsPassword
774
+ : undefined;
775
+ // The lock first (no guard can lift it); none while busy, when the guards were met and
776
+ // the spinner is the state; then the caller's guard — about the fields drawn above the
777
+ // built-in ones, and about WHAT is confirmed — then the first built-in guard still open.
778
+ const confirmReason: ReactNode = locked
779
+ ? lockedReason
780
+ : busy
781
+ ? undefined
782
+ : callerHeld
783
+ ? confirmDisabledReason
784
+ : guardReason;
552
785
 
553
786
  const submit = (e: FormEvent) => {
554
787
  e.preventDefault();
555
- // `locked` too: Enter in a field submits the form without the button's say.
788
+ // `locked` too: Enter in a field submits the form without the button's say (and
789
+ // `canConfirm` holds the caller's guard the same way).
556
790
  if (!canConfirm || locked) return;
791
+ const values: DangerConfirmValues = {
792
+ ...(phrase !== undefined && { typed: phraseMatch === "exact" ? typed : typed.trim() }),
793
+ ...(requirePassword && { password }),
794
+ ...(asksAcknowledge && { acknowledged: true as const }),
795
+ };
796
+ confirmed.current = true;
557
797
  // Disarms when it resolves; a rejection leaves it armed, fields kept: the caller
558
798
  // shows why, the user retries.
559
- run(onConfirm(requirePassword ? password : undefined), () => setArmed(false));
799
+ run(onConfirm(values.password, values), () => setArmed(false));
560
800
  };
561
801
 
562
802
  // The first field takes focus on arm: the tick, then the phrase, then the password —
@@ -634,6 +874,13 @@ export function DangerConfirm({
634
874
  ))}
635
875
  </ul>
636
876
  )}
877
+ {/* The caller's fields: after what the action does, before the proof that it is
878
+ meant. No wrapper without them — a 0.23 tile's markup is unchanged. */}
879
+ {hasFields && (
880
+ <div ref={slotRef} className="space-y-2">
881
+ {children}
882
+ </div>
883
+ )}
637
884
  {asksAcknowledge && (
638
885
  <Checkbox
639
886
  ref={firstField === "acknowledge" ? firstFieldRef : undefined}
@@ -673,13 +920,16 @@ export function DangerConfirm({
673
920
  {labels.cancel}
674
921
  </Button>
675
922
  <Button
923
+ id={confirmId}
676
924
  type="submit"
677
- variant={tone === "warning" ? "primary" : "danger"}
925
+ variant={confirmVariant ?? (tone === "warning" ? "primary" : "danger")}
678
926
  disabled={!canConfirm}
679
- // Armed, then locked (or rendered armed under a lock): the confirm says why
680
- // the way Button does — focusable, `aria-disabled`, the reason in its
681
- // tooltip and description — and the fields stay as typed.
682
- disabledReason={locked ? lockedReason : undefined}
927
+ // Held by a guard (the caller's or a built-in one), or armed and then locked
928
+ // (or rendered armed under a lock): the confirm says why the way Button
929
+ // does — focusable, `aria-disabled`, the reason in its tooltip and
930
+ // description, a press swallowed (the submit with it) — and the fields stay
931
+ // as typed. Only busy is the native `disabled`.
932
+ disabledReason={confirmReason}
683
933
  aria-busy={busy || undefined}
684
934
  >
685
935
  {busy && <Spinner label={null} className="h-4 w-4" />}