@eifi1/ui-kit 0.21.0 → 0.23.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 (320) hide show
  1. package/README.md +42 -25
  2. package/dist/chart.d.ts +19 -14
  3. package/dist/components/account-chips.d.ts +10 -3
  4. package/dist/components/account-chips.js +5 -2
  5. package/dist/components/account-chips.js.map +1 -1
  6. package/dist/components/account-settings.d.ts +33 -2
  7. package/dist/components/account-settings.js +43 -21
  8. package/dist/components/account-settings.js.map +1 -1
  9. package/dist/components/amount-input.d.ts +53 -14
  10. package/dist/components/amount-input.js +17 -28
  11. package/dist/components/amount-input.js.map +1 -1
  12. package/dist/components/autocomplete.d.ts +29 -0
  13. package/dist/components/autocomplete.js +134 -104
  14. package/dist/components/autocomplete.js.map +1 -1
  15. package/dist/components/button-group.d.ts +19 -14
  16. package/dist/components/calculator.d.ts +19 -14
  17. package/dist/components/checkbox-group.d.ts +104 -0
  18. package/dist/components/checkbox-group.js +111 -0
  19. package/dist/components/checkbox-group.js.map +1 -0
  20. package/dist/components/checkbox.d.ts +19 -0
  21. package/dist/components/checkbox.js +30 -7
  22. package/dist/components/checkbox.js.map +1 -1
  23. package/dist/components/chip.d.ts +18 -0
  24. package/dist/components/chip.js +47 -10
  25. package/dist/components/chip.js.map +1 -1
  26. package/dist/components/column-mapper.d.ts +182 -0
  27. package/dist/components/column-mapper.js +375 -0
  28. package/dist/components/column-mapper.js.map +1 -0
  29. package/dist/components/combobox-core.d.ts +39 -2
  30. package/dist/components/combobox-core.js +29 -6
  31. package/dist/components/combobox-core.js.map +1 -1
  32. package/dist/components/combobox.d.ts +91 -2
  33. package/dist/components/combobox.js +454 -308
  34. package/dist/components/combobox.js.map +1 -1
  35. package/dist/components/confirm-dialog.d.ts +69 -161
  36. package/dist/components/confirm-dialog.js +7 -8
  37. package/dist/components/confirm-dialog.js.map +1 -1
  38. package/dist/components/copy-button.d.ts +16 -11
  39. package/dist/components/country-select.d.ts +167 -0
  40. package/dist/components/country-select.js +293 -0
  41. package/dist/components/country-select.js.map +1 -0
  42. package/dist/components/currency-select.d.ts +17 -1
  43. package/dist/components/currency-select.js +56 -23
  44. package/dist/components/currency-select.js.map +1 -1
  45. package/dist/components/danger-confirm.d.ts +69 -166
  46. package/dist/components/danger-confirm.js +144 -50
  47. package/dist/components/danger-confirm.js.map +1 -1
  48. package/dist/components/data-table-cells.d.ts +1 -2
  49. package/dist/components/data-table-filter-popover.d.ts +1 -2
  50. package/dist/components/data-table-filters.d.ts +1 -2
  51. package/dist/components/data-table-labels.d.ts +4 -97
  52. package/dist/components/data-table-labels.js +9 -1
  53. package/dist/components/data-table-labels.js.map +1 -1
  54. package/dist/components/data-table-pagination.d.ts +3 -1
  55. package/dist/components/data-table.d.ts +1 -2
  56. package/dist/components/data-table.js +98 -3
  57. package/dist/components/data-table.js.map +1 -1
  58. package/dist/components/date-picker.d.ts +66 -5
  59. package/dist/components/date-picker.js +378 -66
  60. package/dist/components/date-picker.js.map +1 -1
  61. package/dist/components/entity-combobox.d.ts +42 -1
  62. package/dist/components/entity-combobox.js +154 -113
  63. package/dist/components/entity-combobox.js.map +1 -1
  64. package/dist/components/facing-pair.d.ts +19 -14
  65. package/dist/components/field-parts.d.ts +224 -0
  66. package/dist/components/field-parts.js +156 -0
  67. package/dist/components/field-parts.js.map +1 -0
  68. package/dist/components/field-strip.d.ts +105 -0
  69. package/dist/components/field-strip.js +46 -0
  70. package/dist/components/field-strip.js.map +1 -0
  71. package/dist/components/file-button.d.ts +19 -14
  72. package/dist/components/file-button.js +13 -3
  73. package/dist/components/file-button.js.map +1 -1
  74. package/dist/components/file-dropzone.d.ts +41 -15
  75. package/dist/components/file-dropzone.js +57 -34
  76. package/dist/components/file-dropzone.js.map +1 -1
  77. package/dist/components/form-actions.d.ts +18 -13
  78. package/dist/components/form-actions.js +69 -5
  79. package/dist/components/form-actions.js.map +1 -1
  80. package/dist/components/iban-input.d.ts +69 -0
  81. package/dist/components/iban-input.js +125 -0
  82. package/dist/components/iban-input.js.map +1 -0
  83. package/dist/components/icon-picker.d.ts +21 -2
  84. package/dist/components/icon-picker.js +19 -4
  85. package/dist/components/icon-picker.js.map +1 -1
  86. package/dist/components/inline-edit-field.d.ts +167 -0
  87. package/dist/components/inline-edit-field.js +293 -0
  88. package/dist/components/inline-edit-field.js.map +1 -0
  89. package/dist/components/language-select.d.ts +130 -0
  90. package/dist/components/language-select.js +18 -0
  91. package/dist/components/language-select.js.map +1 -0
  92. package/dist/components/line-items.d.ts +25 -3
  93. package/dist/components/line-items.js +55 -46
  94. package/dist/components/line-items.js.map +1 -1
  95. package/dist/components/list.d.ts +25 -0
  96. package/dist/components/list.js +16 -5
  97. package/dist/components/list.js.map +1 -1
  98. package/dist/components/money-field.d.ts +32 -14
  99. package/dist/components/money-field.js.map +1 -1
  100. package/dist/components/month-picker.d.ts +52 -4
  101. package/dist/components/month-picker.js +291 -86
  102. package/dist/components/month-picker.js.map +1 -1
  103. package/dist/components/multi-entity-combobox.d.ts +26 -1
  104. package/dist/components/multi-entity-combobox.js +143 -102
  105. package/dist/components/multi-entity-combobox.js.map +1 -1
  106. package/dist/components/multi-select.d.ts +8 -1
  107. package/dist/components/multi-select.js +9 -4
  108. package/dist/components/multi-select.js.map +1 -1
  109. package/dist/components/number-field.d.ts +34 -19
  110. package/dist/components/number-field.js +6 -20
  111. package/dist/components/number-field.js.map +1 -1
  112. package/dist/components/number-input.d.ts +79 -19
  113. package/dist/components/number-input.js +90 -38
  114. package/dist/components/number-input.js.map +1 -1
  115. package/dist/components/numpad-sheet.d.ts +19 -14
  116. package/dist/components/one-time-code-input.d.ts +100 -0
  117. package/dist/components/one-time-code-input.js +137 -0
  118. package/dist/components/one-time-code-input.js.map +1 -0
  119. package/dist/components/phone-input.d.ts +69 -0
  120. package/dist/components/phone-input.js +171 -0
  121. package/dist/components/phone-input.js.map +1 -0
  122. package/dist/components/reauth-dialog.d.ts +2 -2
  123. package/dist/components/reauth-dialog.js +6 -8
  124. package/dist/components/reauth-dialog.js.map +1 -1
  125. package/dist/components/series-chart.d.ts +19 -14
  126. package/dist/components/settings-fields.d.ts +19 -14
  127. package/dist/components/share-card.d.ts +16 -11
  128. package/dist/components/sign-chip.d.ts +66 -0
  129. package/dist/components/sign-chip.js +39 -0
  130. package/dist/components/sign-chip.js.map +1 -0
  131. package/dist/components/swatch-picker.d.ts +43 -2
  132. package/dist/components/swatch-picker.js +80 -32
  133. package/dist/components/swatch-picker.js.map +1 -1
  134. package/dist/components/switch.d.ts +19 -0
  135. package/dist/components/switch.js +24 -6
  136. package/dist/components/switch.js.map +1 -1
  137. package/dist/components/text-link.d.ts +18 -13
  138. package/dist/components/tile-radio.d.ts +88 -13
  139. package/dist/components/tile-radio.js +28 -11
  140. package/dist/components/tile-radio.js.map +1 -1
  141. package/dist/components/time-input.d.ts +24 -15
  142. package/dist/components/time-input.js.map +1 -1
  143. package/dist/components/toggle-group.d.ts +47 -1
  144. package/dist/components/toggle-group.js +110 -69
  145. package/dist/components/toggle-group.js.map +1 -1
  146. package/dist/components/trigger-aria.d.ts +3 -0
  147. package/dist/components/trigger-aria.js +3 -1
  148. package/dist/components/trigger-aria.js.map +1 -1
  149. package/dist/components/ui.d.ts +16 -11
  150. package/dist/components/ui.js +284 -88
  151. package/dist/components/ui.js.map +1 -1
  152. package/dist/components/use-table-state.d.ts +1 -2
  153. package/dist/{data-table-3mi2R3so.d.ts → data-table-labels-B7OdnM0S.d.ts} +140 -3
  154. package/dist/data-table.d.ts +1 -2
  155. package/dist/data-table.js +2 -1
  156. package/dist/data-table.js.map +1 -1
  157. package/dist/feedback/feedback-attachment.d.ts +1 -1
  158. package/dist/feedback/feedback-attachment.js +348 -41
  159. package/dist/feedback/feedback-attachment.js.map +1 -1
  160. package/dist/feedback/feedback-dialog.d.ts +1 -1
  161. package/dist/feedback/feedback-dialog.js.map +1 -1
  162. package/dist/feedback/feedback-inbox.d.ts +3 -2
  163. package/dist/feedback/feedback-inbox.js.map +1 -1
  164. package/dist/feedback/feedback-thread.d.ts +68 -253
  165. package/dist/feedback/feedback-thread.js +46 -10
  166. package/dist/feedback/feedback-thread.js.map +1 -1
  167. package/dist/{kit-labels-D1J2_jyD.d.ts → feedback-BxeQVzwq.d.ts} +1206 -20
  168. package/dist/{feedback-attachment-WFiXJ8We.d.ts → feedback-attachment-fGAzZPf0.d.ts} +178 -11
  169. package/dist/feedback.d.ts +66 -2
  170. package/dist/hooks/use-file-drop.d.ts +19 -14
  171. package/dist/i18n/defaults.d.ts +19 -14
  172. package/dist/i18n/defaults.js +19 -2
  173. package/dist/i18n/defaults.js.map +1 -1
  174. package/dist/i18n/german.d.ts +19 -14
  175. package/dist/i18n/german.js +84 -5
  176. package/dist/i18n/german.js.map +1 -1
  177. package/dist/i18n/kit-labels.d.ts +15 -10
  178. package/dist/i18n/kit-labels.js +8 -2
  179. package/dist/i18n/kit-labels.js.map +1 -1
  180. package/dist/i18n/languages.d.ts +19 -14
  181. package/dist/i18n/locales/de-CH.d.ts +19 -14
  182. package/dist/i18n/locales/en.d.ts +19 -14
  183. package/dist/i18n/locales/en.js +16 -0
  184. package/dist/i18n/locales/en.js.map +1 -1
  185. package/dist/i18n/locales/es.d.ts +19 -14
  186. package/dist/i18n/locales/es.js +83 -5
  187. package/dist/i18n/locales/es.js.map +1 -1
  188. package/dist/i18n/locales/fr.d.ts +19 -14
  189. package/dist/i18n/locales/fr.js +82 -5
  190. package/dist/i18n/locales/fr.js.map +1 -1
  191. package/dist/i18n/locales/hu.d.ts +19 -14
  192. package/dist/i18n/locales/hu.js +85 -5
  193. package/dist/i18n/locales/hu.js.map +1 -1
  194. package/dist/i18n/locales/it.d.ts +19 -14
  195. package/dist/i18n/locales/it.js +84 -5
  196. package/dist/i18n/locales/it.js.map +1 -1
  197. package/dist/i18n/locales/zh.d.ts +19 -14
  198. package/dist/i18n/locales/zh.js +80 -5
  199. package/dist/i18n/locales/zh.js.map +1 -1
  200. package/dist/i18n/review.d.ts +19 -14
  201. package/dist/i18n/review.js +27 -1
  202. package/dist/i18n/review.js.map +1 -1
  203. package/dist/index.d.ts +21 -10
  204. package/dist/index.js +73 -2
  205. package/dist/index.js.map +1 -1
  206. package/dist/lib/column-mapping.d.ts +81 -0
  207. package/dist/lib/column-mapping.js +108 -0
  208. package/dist/lib/column-mapping.js.map +1 -0
  209. package/dist/lib/countries.d.ts +43 -0
  210. package/dist/lib/countries.js +39 -0
  211. package/dist/lib/countries.js.map +1 -0
  212. package/dist/lib/decimal-marks.d.ts +46 -0
  213. package/dist/lib/decimal-marks.js +40 -0
  214. package/dist/lib/decimal-marks.js.map +1 -0
  215. package/dist/lib/iban.d.ts +98 -0
  216. package/dist/lib/iban.js +154 -0
  217. package/dist/lib/iban.js.map +1 -0
  218. package/dist/lib/isin.d.ts +30 -0
  219. package/dist/lib/isin.js +36 -0
  220. package/dist/lib/isin.js.map +1 -0
  221. package/dist/lib/phone.d.ts +69 -0
  222. package/dist/lib/phone.js +121 -0
  223. package/dist/lib/phone.js.map +1 -0
  224. package/dist/lib/table-text.d.ts +109 -1
  225. package/dist/lib/table-text.js +122 -1
  226. package/dist/lib/table-text.js.map +1 -1
  227. package/dist/rhf/fields.d.ts +233 -18
  228. package/dist/rhf/fields.js +456 -3
  229. package/dist/rhf/fields.js.map +1 -1
  230. package/dist/rhf/form.d.ts +19 -14
  231. package/dist/rhf.d.ts +22 -15
  232. package/dist/rhf.js.map +1 -1
  233. package/dist/shell/app-shell.d.ts +18 -13
  234. package/dist/shell/top-bar-brand.d.ts +19 -14
  235. package/dist/shell/topbar-action-menu.d.ts +36 -3
  236. package/dist/shell/topbar-action-menu.js +74 -33
  237. package/dist/shell/topbar-action-menu.js.map +1 -1
  238. package/dist/shell.d.ts +18 -13
  239. package/dist/table-text.d.ts +1 -1
  240. package/dist/tour/tour.js +20 -24
  241. package/dist/tour/tour.js.map +1 -1
  242. package/dist/wizard/stepper-nav.d.ts +49 -16
  243. package/dist/wizard/stepper-nav.js +4 -0
  244. package/dist/wizard/stepper-nav.js.map +1 -1
  245. package/dist/wizard.d.ts +19 -14
  246. package/package.json +1 -1
  247. package/src/components/account-chips.tsx +17 -3
  248. package/src/components/account-settings.tsx +92 -26
  249. package/src/components/amount-input.tsx +68 -56
  250. package/src/components/autocomplete.tsx +198 -114
  251. package/src/components/checkbox-group.tsx +229 -0
  252. package/src/components/checkbox.tsx +72 -8
  253. package/src/components/chip.tsx +80 -10
  254. package/src/components/column-mapper.tsx +666 -0
  255. package/src/components/combobox-core.tsx +82 -9
  256. package/src/components/combobox.tsx +661 -340
  257. package/src/components/confirm-dialog.tsx +12 -8
  258. package/src/components/country-select.tsx +556 -0
  259. package/src/components/currency-select.tsx +109 -33
  260. package/src/components/danger-confirm.tsx +429 -82
  261. package/src/components/data-table-labels.ts +16 -1
  262. package/src/components/data-table.tsx +175 -6
  263. package/src/components/date-picker.tsx +595 -71
  264. package/src/components/entity-combobox.tsx +235 -124
  265. package/src/components/field-parts.tsx +403 -0
  266. package/src/components/field-strip.tsx +149 -0
  267. package/src/components/file-button.tsx +36 -3
  268. package/src/components/file-dropzone.tsx +106 -38
  269. package/src/components/form-actions.tsx +147 -6
  270. package/src/components/iban-input.tsx +231 -0
  271. package/src/components/icon-picker.tsx +47 -6
  272. package/src/components/inline-edit-field.tsx +533 -0
  273. package/src/components/language-select.tsx +78 -0
  274. package/src/components/line-items.tsx +35 -2
  275. package/src/components/list.tsx +55 -6
  276. package/src/components/money-field.tsx +13 -0
  277. package/src/components/month-picker.tsx +400 -63
  278. package/src/components/multi-entity-combobox.tsx +207 -120
  279. package/src/components/multi-select.tsx +80 -58
  280. package/src/components/number-field.tsx +28 -46
  281. package/src/components/number-input.tsx +210 -43
  282. package/src/components/one-time-code-input.tsx +288 -0
  283. package/src/components/phone-input.tsx +299 -0
  284. package/src/components/reauth-dialog.tsx +17 -18
  285. package/src/components/sign-chip.tsx +103 -0
  286. package/src/components/swatch-picker.tsx +143 -31
  287. package/src/components/switch.tsx +58 -7
  288. package/src/components/tile-radio.tsx +145 -29
  289. package/src/components/time-input.tsx +8 -1
  290. package/src/components/toggle-group.tsx +206 -73
  291. package/src/components/trigger-aria.ts +5 -0
  292. package/src/components/ui.tsx +479 -138
  293. package/src/data-table.ts +2 -1
  294. package/src/feedback/feedback-attachment.tsx +597 -46
  295. package/src/feedback/feedback-dialog.tsx +9 -3
  296. package/src/feedback/feedback-inbox.tsx +3 -2
  297. package/src/feedback/feedback-thread.tsx +113 -9
  298. package/src/i18n/defaults.ts +18 -1
  299. package/src/i18n/german.ts +89 -0
  300. package/src/i18n/kit-labels.tsx +82 -2
  301. package/src/i18n/locales/en.ts +36 -4
  302. package/src/i18n/locales/es.ts +87 -0
  303. package/src/i18n/locales/fr.ts +87 -0
  304. package/src/i18n/locales/hu.ts +83 -0
  305. package/src/i18n/locales/it.ts +88 -0
  306. package/src/i18n/locales/zh.ts +76 -0
  307. package/src/i18n/review.ts +26 -0
  308. package/src/index.ts +71 -3
  309. package/src/lib/column-mapping.ts +234 -0
  310. package/src/lib/countries.ts +91 -0
  311. package/src/lib/decimal-marks.ts +99 -0
  312. package/src/lib/iban.ts +171 -0
  313. package/src/lib/isin.ts +62 -0
  314. package/src/lib/phone.ts +211 -0
  315. package/src/lib/table-text.ts +271 -0
  316. package/src/rhf/fields.tsx +860 -9
  317. package/src/rhf.ts +3 -1
  318. package/src/shell/topbar-action-menu.tsx +134 -38
  319. package/src/tour/tour.tsx +66 -28
  320. package/src/wizard/stepper-nav.tsx +35 -1
@@ -1,9 +1,12 @@
1
- import { useCallback, useEffect, useId, useRef, useState } from "react";
2
- import type { ChangeEvent, ComponentPropsWithoutRef, FormEvent, ReactNode, Ref } from "react";
1
+ import { forwardRef, useCallback, useEffect, useId, useRef, useState } from "react";
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";
7
+ import { Checkbox } from "./checkbox";
6
8
  import { Tooltip } from "./tooltip";
9
+ import { useCommitReason } from "./write-lock";
7
10
 
8
11
  /**
9
12
  * How typed text is compared with the text it has to repeat — shared by
@@ -67,47 +70,229 @@ export function usePromisePending() {
67
70
  return { pending, run };
68
71
  }
69
72
 
73
+ /** A label that is really there — `null`, `false` and `""` are what a caller's
74
+ * `cond && reason` evaluates to on the path where there is none. */
75
+ function hasContent(node: ReactNode): boolean {
76
+ return node !== undefined && node !== null && node !== false && node !== "";
77
+ }
78
+
79
+ /** The one word {@link CurrentPasswordInput} says on its own behalf — the
80
+ * `dangerConfirm.password` label, shared with DangerConfirm's password field, so an
81
+ * app translates "Password" once. */
82
+ export type CurrentPasswordInputLabels = Pick<DangerConfirmLabels, "password">;
83
+
70
84
  /**
71
- * The "prove it is you" field: a password input the browser's password manager fills
72
- * with the CURRENT password (`autocomplete="current-password"`), never offers to
73
- * generate a new one for. `readOnly` rather than `disabled` while busy: disabling the
74
- * field that has focus (Enter was pressed in it) drops focus to <body>.
85
+ * Everything {@link Input} takes except what makes this field what it is: `type` is
86
+ * always `"password"` and `autoComplete` always `"current-password"`.
87
+ */
88
+ export interface CurrentPasswordInputProps extends Omit<InputProps, "type" | "autoComplete" | "label"> {
89
+ /**
90
+ * The visible label. Default `dangerConfirm.password` ("Password") — unless the field
91
+ * is named another way (`aria-label` / `aria-labelledby`), which then stays the only
92
+ * name. Pass the app's own word on a sign-in form ("Password", "Your password"), or
93
+ * `null` for a field whose `<label htmlFor>` is drawn elsewhere (a form library's
94
+ * `FormLabel`), which would otherwise be labelled twice.
95
+ */
96
+ label?: ReactNode;
97
+ /** The value as a string — the `(next) => …` shape, so a caller holding it in
98
+ * `useState` need not unwrap `event.target.value`. Fires alongside `onChange`,
99
+ * never instead of it, so a form library's `onChange` keeps working. */
100
+ onValueChange?: (value: string) => void;
101
+ /**
102
+ * A check is running (the sign-in request, the re-auth). The field turns `readOnly`,
103
+ * NOT `disabled`: the field being submitted is usually the focused one (Enter was
104
+ * pressed in it), and disabling a focused field drops focus to `<body>` — the user
105
+ * lands nowhere when the "wrong password" answer comes back.
106
+ */
107
+ busy?: boolean;
108
+ /** Per-instance override of `dangerConfirm.password`. */
109
+ labels?: Partial<CurrentPasswordInputLabels>;
110
+ }
111
+
112
+ /**
113
+ * The "prove it is you" field: a password input that a password manager fills with
114
+ * the CURRENT password and never offers to generate a new one for.
115
+ *
116
+ * It is the field of a sign-in form, a re-auth step ({@link ReauthDialog} draws the same
117
+ * field), and {@link DangerConfirm}'s `requirePassword`. Public since 0.22 for kastlan's
118
+ * sign-in and re-auth (kastlan 1), which wrote `type="password"` +
119
+ * `autoComplete="current-password"` by hand — and a sign-in field that forgets the
120
+ * second half gets the browser's "suggest a strong password" offer on the one form where
121
+ * a NEW password is never what is wanted. The reverse matters as much: a field that sets
122
+ * a NEW password (a passphrase, a sign-up) must NOT be this one — keksdose #170 keeps
123
+ * its passphrase doors on `new-password` / `off` for that reason.
124
+ *
125
+ * Everything else is {@link Input}: the reveal toggle, `error` (under the field,
126
+ * `aria-describedby`, `aria-invalid`), `invalid`, `name`, `id`, `autoFocus`, `required`.
127
+ * The ref is the `<input>`, so it works with `@eifi1/ui-kit/rhf`'s `FormControl` /
128
+ * a `register()` spread, and controlled or uncontrolled.
75
129
  *
76
- * @internal DangerConfirm's field. ReauthDialog spells it out with the field's own
77
- * `error`, which keeps the `<input>` mounted while its message comes and goes.
130
+ * ```tsx
131
+ * <CurrentPasswordInput name="password" label={t("password")} error={errors.password?.message}
132
+ * busy={isSubmitting} {...register("password")} />
133
+ * ```
78
134
  */
79
- export function CurrentPasswordInput({
80
- inputRef,
81
- label,
82
- value,
83
- onValueChange,
84
- busy,
85
- invalid,
86
- describedBy,
87
- }: {
88
- inputRef?: Ref<HTMLInputElement>;
89
- label: string;
90
- value: string;
91
- onValueChange: (value: string) => void;
92
- busy: boolean;
93
- invalid?: boolean;
94
- describedBy?: string;
95
- }) {
96
- return (
97
- <Input
98
- ref={inputRef}
99
- type="password"
100
- autoComplete="current-password"
101
- label={label}
102
- value={value}
103
- readOnly={busy}
104
- invalid={invalid}
105
- aria-describedby={describedBy}
106
- onChange={(e) => onValueChange(e.target.value)}
107
- />
108
- );
135
+ export const CurrentPasswordInput = forwardRef<HTMLInputElement, CurrentPasswordInputProps>(
136
+ function CurrentPasswordInput({ label, onValueChange, onChange, busy, readOnly, labels: labelsProp, ...rest }, ref) {
137
+ const labels = useKitLabels("dangerConfirm", DEFAULT_DANGER_CONFIRM_LABELS, labelsProp);
138
+ const namedOtherwise = rest["aria-label"] !== undefined || rest["aria-labelledby"] !== undefined;
139
+ return (
140
+ <Input
141
+ {...rest}
142
+ ref={ref}
143
+ // After the spread: a props object spread at the field must not turn it into a
144
+ // text field, or into one the password manager offers a new password for.
145
+ type="password"
146
+ autoComplete="current-password"
147
+ label={label === undefined ? (namedOtherwise ? undefined : labels.password) : hasContent(label) ? label : undefined}
148
+ readOnly={Boolean(busy) || readOnly}
149
+ onChange={(e) => {
150
+ onChange?.(e);
151
+ onValueChange?.(e.target.value);
152
+ }}
153
+ />
154
+ );
155
+ },
156
+ );
157
+ CurrentPasswordInput.displayName = "CurrentPasswordInput";
158
+
159
+ /** The words {@link TypedConfirmField} says on its own behalf — the `dangerConfirm`
160
+ * namespace's `phrase` and `phrasePlaceholder`, shared with DangerConfirm's own field,
161
+ * so the sentence "Type “X” to confirm" is translated once. */
162
+ export type TypedConfirmFieldLabels = Pick<DangerConfirmLabels, "phrase" | "phrasePlaceholder">;
163
+
164
+ export interface TypedConfirmFieldProps
165
+ extends Omit<InputProps, "type" | "value" | "defaultValue" | "label" | "placeholder"> {
166
+ /** The text to repeat — a word ("DELETE"), a name, an e-mail address. */
167
+ target: string;
168
+ /** How the typed text is compared with `target` — {@link TypedMatch}. Default
169
+ * `"trim"`, as {@link typedMatches}; `"caseless"` for an address or an
170
+ * acknowledgement word a user may type in any case. */
171
+ match?: TypedMatch;
172
+ /** Controlled value. Left out, the field keeps its own (start: `defaultValue`). */
173
+ value?: string;
174
+ defaultValue?: string;
175
+ /** The typed text as a string, on every keystroke. Fires alongside `onChange`. */
176
+ onValueChange?: (value: string) => void;
177
+ /**
178
+ * The typed text started or stopped matching `target` — fired from the keystroke
179
+ * that flipped it, so an uncontrolled field can still drive the button it guards. A
180
+ * controlled caller that resets the value itself (on a close) knows the answer
181
+ * already, and gets no call for that; it can also compute it with {@link typedMatches}.
182
+ */
183
+ onMatchedChange?: (matched: boolean) => void;
184
+ /** The label. Default `dangerConfirm.phrase(target)` — "Type “DELETE” to confirm". */
185
+ label?: ReactNode;
186
+ /** Text inside the empty field. Default `dangerConfirm.phrasePlaceholder` (unset).
187
+ * Given, the label moves ABOVE the field: a floating label occupies the empty
188
+ * field, which is exactly where a placeholder shows. `""` means none. */
189
+ placeholder?: string;
190
+ /** The guarded action is running: the field turns `readOnly` (not `disabled`, which
191
+ * would drop the focus Enter left in it). */
192
+ busy?: boolean;
193
+ labels?: Partial<TypedConfirmFieldLabels>;
109
194
  }
110
195
 
196
+ /**
197
+ * The "type this to confirm" field — {@link DangerConfirm}'s `phrase` field and
198
+ * `useConfirm({ requireTyped })`'s, on its own (keksdose K7).
199
+ *
200
+ * keksdose writes it by hand four times: the privacy enrol, upgrade and sweep dialogs
201
+ * (type the acknowledgement word, then the key doors appear) and the admin's "type the
202
+ * user's address" (`UserActionConfirm`), each an `Input` plus its own
203
+ * `trim().toUpperCase()` / `toLowerCase()` comparison. None of those places is an
204
+ * arm → confirm tile or a modal confirm — what is guarded is a whole form, or the
205
+ * next step of a dialog — so the field had to stand alone. What it brings over a plain
206
+ * `Input`: one match rule ({@link typedMatches}, which lower-cases locale-independently
207
+ * on purpose), the label worded from the target in the app's language, and the
208
+ * attributes a phone keyboard needs to leave the text alone (no autocomplete, no
209
+ * auto-capital, no autocorrect, no spellcheck — a keyboard that "corrects" an e-mail
210
+ * address makes the guard unpassable).
211
+ *
212
+ * The field reports whether it matches (`onMatchedChange`, and `data-matched` on the
213
+ * `<input>` for styling); what the match unlocks is the caller's. The ref is the
214
+ * `<input>`.
215
+ *
216
+ * ```tsx
217
+ * const [ok, setOk] = useState(false);
218
+ * <TypedConfirmField target={t("privacy.enroll_ack_word")} match="caseless" onMatchedChange={setOk} />
219
+ * {ok && <KeyDoorsForm … />}
220
+ * ```
221
+ */
222
+ export const TypedConfirmField = forwardRef<HTMLInputElement, TypedConfirmFieldProps>(function TypedConfirmField(
223
+ {
224
+ target,
225
+ match = "trim",
226
+ value: valueProp,
227
+ defaultValue = "",
228
+ onValueChange,
229
+ onMatchedChange,
230
+ onChange,
231
+ label: labelProp,
232
+ placeholder: placeholderProp,
233
+ busy,
234
+ readOnly,
235
+ labels: labelsProp,
236
+ id,
237
+ className,
238
+ ...rest
239
+ },
240
+ ref,
241
+ ) {
242
+ const labels = useKitLabels("dangerConfirm", DEFAULT_DANGER_CONFIRM_LABELS, labelsProp);
243
+ const [own, setOwn] = useState(defaultValue);
244
+ const value = valueProp ?? own;
245
+ const matched = typedMatches(value, target, match);
246
+ const generated = useId();
247
+ const fieldId = id ?? generated;
248
+ const label =
249
+ labelProp !== undefined
250
+ ? labelProp
251
+ : typeof labels.phrase === "function"
252
+ ? labels.phrase(target)
253
+ : labels.phrase;
254
+ const fromLabels =
255
+ labels.phrasePlaceholder === undefined
256
+ ? undefined
257
+ : typeof labels.phrasePlaceholder === "function"
258
+ ? labels.phrasePlaceholder(target)
259
+ : labels.phrasePlaceholder;
260
+ const placeholder = placeholderProp ?? fromLabels;
261
+
262
+ const fieldProps = {
263
+ ...rest,
264
+ ref,
265
+ id: fieldId,
266
+ value,
267
+ autoComplete: "off",
268
+ autoCapitalize: "off",
269
+ autoCorrect: "off",
270
+ spellCheck: false,
271
+ readOnly: Boolean(busy) || readOnly,
272
+ "data-matched": matched || undefined,
273
+ onChange: (e: ChangeEvent<HTMLInputElement>) => {
274
+ onChange?.(e);
275
+ const next = e.target.value;
276
+ if (valueProp === undefined) setOwn(next);
277
+ onValueChange?.(next);
278
+ const nextMatched = typedMatches(next, target, match);
279
+ if (nextMatched !== matched) onMatchedChange?.(nextMatched);
280
+ },
281
+ };
282
+
283
+ if (placeholder) {
284
+ return (
285
+ // A static label above, so the placeholder has the empty field to itself.
286
+ <div className={cn("space-y-1.5", className)}>
287
+ <Label htmlFor={fieldId}>{label}</Label>
288
+ <Input {...fieldProps} placeholder={placeholder} />
289
+ </div>
290
+ );
291
+ }
292
+ return <Input {...fieldProps} label={label} className={className} />;
293
+ });
294
+ TypedConfirmField.displayName = "TypedConfirmField";
295
+
111
296
  /**
112
297
  * Every string the tile renders — the `dangerConfirm` namespace of
113
298
  * `<UiKitProvider labels>`, overridable per instance through `labels`. The arm and
@@ -136,6 +321,21 @@ export interface DangerConfirmLabels {
136
321
  * label occupies the empty field, which is exactly where a placeholder shows) and
137
322
  * the placeholder fills the field. An empty string means none. */
138
323
  phrasePlaceholder?: string | ((phrase: string) => string);
324
+ /** 0.22: the checkbox `requireAcknowledge={true}` shows — "I have read what this does
325
+ * and want to continue" (keksdose's `admin.users.confirm_ack`). */
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;
139
339
  }
140
340
 
141
341
  /** `satisfies` rather than a type annotation, so `DEFAULT_DANGER_CONFIRM_LABELS.phrase`
@@ -147,8 +347,48 @@ export const DEFAULT_DANGER_CONFIRM_LABELS = {
147
347
  prompt: "This cannot be undone.",
148
348
  password: "Password",
149
349
  phrase: (phrase: string) => `Type “${phrase}” to confirm`,
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",
150
354
  } satisfies DangerConfirmLabels;
151
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
+
378
+ /**
379
+ * One line of {@link DangerConfirmProps.consequences}: what the action will do. A plain
380
+ * string, or this, for a key of its own and the `severe` mark.
381
+ */
382
+ export interface DangerConsequence {
383
+ /** React key — keksdose keys its lines by what they say ("api_tokens", "only_door").
384
+ * Default: the text when it is a string, else the position. */
385
+ key?: string;
386
+ text: ReactNode;
387
+ /** "There is no way back" — painted in the danger colour rather than the list's
388
+ * muted one. keksdose reserves it for the one line that loses something for good. */
389
+ severe?: boolean;
390
+ }
391
+
152
392
  export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">, "onChange"> {
153
393
  /**
154
394
  * Runs the action, with the entered password when `requirePassword` is on.
@@ -157,12 +397,47 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
157
397
  * (fields wiped) when it resolves, still armed when it rejects — so the user can
158
398
  * correct a wrong password and retry. The rejection is not swallowed for you to
159
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.
160
413
  */
161
- onConfirm: (password?: string) => void | Promise<unknown>;
162
- /** 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. */
163
416
  requirePassword?: boolean;
164
- /** Show a "type <phrase> to confirm" field; confirm stays disabled until the field
165
- * matches (case-sensitive; surrounding spaces ignored unless `phraseMatch="exact"`). */
417
+ /**
418
+ * Show an "I understand" checkbox; confirm is held until it is ticked
419
+ * (keksdose K7). `true` words it with `labels.acknowledge`; any other node IS the
420
+ * checkbox's label ("I understand Anna will be signed out everywhere").
421
+ *
422
+ * The guard for an action whose risk is MIS-TARGETING less than carelessness:
423
+ * keksdose's admin password reset asks for a typed address when the account has an
424
+ * encrypted door to lose, and a tick otherwise (`UserActionConfirm`), and its plan
425
+ * editor asks for the tick alone (`UserPlanEditor`). Both were hand-built beside this
426
+ * tile because it had no tick. It combines with `phrase` and `requirePassword`: every
427
+ * guard given must be satisfied.
428
+ */
429
+ requireAcknowledge?: ReactNode;
430
+ /**
431
+ * What the action will do, said before it happens — a list under the prompt, shown
432
+ * once armed (keksdose K7, `UserActionConfirm`'s consequences). Strings, or
433
+ * {@link DangerConsequence}s for a `severe` line in the danger colour.
434
+ *
435
+ * Derive them from the row's own facts rather than reciting the worst case: an admin
436
+ * who reads the same paragraph on every row stops reading it (keksdose dev#488).
437
+ */
438
+ consequences?: readonly (string | DangerConsequence)[];
439
+ /** Show a "type <phrase> to confirm" field; confirm is held until the field matches
440
+ * (case-sensitive; surrounding spaces ignored unless `phraseMatch="exact"`). */
166
441
  phrase?: string;
167
442
  /**
168
443
  * How the typed text is compared with `phrase`. `"trim"` (default) ignores spaces
@@ -195,9 +470,20 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
195
470
  * stays focusable (`aria-disabled`) so a keyboard user can land on it and hear why.
196
471
  *
197
472
  * On the ARM button rather than the confirm: arming asks for a password, and asking
198
- * for a password for a write that can never land is the worse of the two.
473
+ * for a password for a write that can never land is the worse of the two. A tile that
474
+ * is ALREADY armed when the reason arrives (controlled `armed`, or a lock that landed
475
+ * while it was open) keeps its fields but its confirm takes the reason the same way
476
+ * — focusable, `aria-disabled`, the reason in its tooltip — and Enter does nothing.
199
477
  */
200
478
  lockedReason?: ReactNode;
479
+ /**
480
+ * This tile COMMITS — the 0.18 write-lock opt-in that {@link Button} has (keksdose
481
+ * K3). Under a locked {@link WriteLockProvider} the lock's reason becomes
482
+ * `lockedReason` (and wins over one of the tile's own), so a page that mounts the
483
+ * provider no longer threads `lockedReason={lock.locked ? lock.reason : undefined}`
484
+ * into every tile. No provider, or an unlocked one: no effect.
485
+ */
486
+ commit?: boolean;
201
487
  /** Controlled armed state. The parent can then collapse the tile from a mutation's
202
488
  * own `onSuccess` without returning a promise. */
203
489
  armed?: boolean;
@@ -209,8 +495,18 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
209
495
 
210
496
  /**
211
497
  * An "arm → confirm" tile for destructive actions: one button, which expands into a
212
- * warning, an optional password field, an optional type-to-confirm field and a
213
- * confirm that stays disabled until every guard is satisfied.
498
+ * warning, an optional list of consequences, an optional "I understand" tick, an
499
+ * optional type-to-confirm field, an optional password field and a confirm that is held
500
+ * until every guard is satisfied.
501
+ *
502
+ * A held confirm SAYS which guard is still open (0.23, keksdose G4a): it is
503
+ * `aria-disabled` rather than `disabled` — still focusable, so a keyboard user can land
504
+ * on it — with the reason in the kit {@link Tooltip} and its description ("Type
505
+ * “DELETE” to confirm", "Tick the box to confirm", "Enter your password to confirm";
506
+ * `labels.needs*`), the first open guard in the order they are drawn. FormActions'
507
+ * `submitDisabledReason` does the same for a form's Save. Pressing it, or Enter in a
508
+ * field, does nothing. A lock's reason wins over a guard's; while the action runs the
509
+ * confirm is plainly disabled, its spinner saying why.
214
510
  *
215
511
  * Keksdose hand-rolled it three times (load demo data, wipe everything, reset a
216
512
  * budget) and then as `shared/components/danger-confirm.tsx`; the only app-specific
@@ -225,6 +521,8 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
225
521
  export function DangerConfirm({
226
522
  onConfirm,
227
523
  requirePassword,
524
+ requireAcknowledge,
525
+ consequences,
228
526
  phrase,
229
527
  phraseMatch = "trim",
230
528
  prompt,
@@ -233,7 +531,8 @@ export function DangerConfirm({
233
531
  confirmLabel,
234
532
  busy: busyProp,
235
533
  disabled,
236
- lockedReason,
534
+ lockedReason: ownLockedReason,
535
+ commit,
237
536
  armed: armedProp,
238
537
  onArmedChange,
239
538
  labels: labelsProp,
@@ -241,17 +540,19 @@ export function DangerConfirm({
241
540
  ...rest
242
541
  }: DangerConfirmProps) {
243
542
  const labels = useKitLabels("dangerConfirm", DEFAULT_DANGER_CONFIRM_LABELS, labelsProp);
543
+ const lockedReason = useCommitReason(commit, ownLockedReason);
244
544
  const [armedState, setArmedState] = useState(false);
245
545
  const armed = armedProp ?? armedState;
246
546
  const [password, setPassword] = useState("");
247
547
  const [typed, setTyped] = useState("");
548
+ const [acknowledged, setAcknowledged] = useState(false);
248
549
  const { pending, run } = usePromisePending();
249
550
  const busy = Boolean(busyProp) || pending;
250
551
  // `readOnly` rather than `disabled` on the fields while busy: disabling the field
251
552
  // that has focus (Enter was pressed in it) drops focus to <body>.
252
553
  const promptId = useId();
554
+ const consequencesId = useId();
253
555
  const reasonId = useId();
254
- const phraseFieldId = useId();
255
556
 
256
557
  // The kit's Button takes no ref, so the two buttons focus is moved to are found by id.
257
558
  const armId = useId();
@@ -270,6 +571,7 @@ export function DangerConfirm({
270
571
  if (!armed) {
271
572
  setPassword("");
272
573
  setTyped("");
574
+ setAcknowledged(false);
273
575
  }
274
576
  }
275
577
 
@@ -296,37 +598,53 @@ export function DangerConfirm({
296
598
  onArmedChange?.(next);
297
599
  };
298
600
 
299
- const locked = lockedReason !== undefined && lockedReason !== null && lockedReason !== false && lockedReason !== "";
601
+ const locked = hasContent(lockedReason);
602
+ // `true` words the tick with the namespace; any other node is its label; `false`,
603
+ // `null` and `""` are no tick at all.
604
+ const acknowledgeLabel: ReactNode =
605
+ requireAcknowledge === true ? labels.acknowledge : hasContent(requireAcknowledge) ? requireAcknowledge : null;
606
+ const asksAcknowledge = acknowledgeLabel !== null;
300
607
  const passwordOk = !requirePassword || password !== "";
301
608
  const phraseOk = phrase === undefined || typedMatches(typed, phrase, phraseMatch);
302
- const canConfirm = passwordOk && phraseOk && !busy;
609
+ const acknowledgeOk = !asksAcknowledge || acknowledged;
610
+ const canConfirm = passwordOk && phraseOk && acknowledgeOk && !busy;
611
+ // The first guard still open, in the order the fields are drawn — the one the user
612
+ // meets next, so the sentence points at it.
613
+ const guardReason: string | undefined = !acknowledgeOk
614
+ ? labels.needsAcknowledge
615
+ : !phraseOk
616
+ ? typeof labels.needsPhrase === "function"
617
+ ? labels.needsPhrase(phrase ?? "")
618
+ : labels.needsPhrase
619
+ : !passwordOk
620
+ ? labels.needsPassword
621
+ : undefined;
622
+ // The lock first (no guard can lift it); none while busy, when the guards were met and
623
+ // the spinner is the state.
624
+ const confirmReason: ReactNode = locked ? lockedReason : busy ? undefined : guardReason;
303
625
 
304
626
  const submit = (e: FormEvent) => {
305
627
  e.preventDefault();
306
- if (!canConfirm) return;
628
+ // `locked` too: Enter in a field submits the form without the button's say.
629
+ if (!canConfirm || locked) return;
630
+ const values: DangerConfirmValues = {
631
+ ...(phrase !== undefined && { typed: phraseMatch === "exact" ? typed : typed.trim() }),
632
+ ...(requirePassword && { password }),
633
+ ...(asksAcknowledge && { acknowledged: true as const }),
634
+ };
307
635
  // Disarms when it resolves; a rejection leaves it armed, fields kept: the caller
308
636
  // shows why, the user retries.
309
- run(onConfirm(requirePassword ? password : undefined), () => setArmed(false));
637
+ run(onConfirm(values.password, values), () => setArmed(false));
310
638
  };
311
639
 
312
- const phrasePlaceholder =
313
- phrase === undefined || labels.phrasePlaceholder === undefined
314
- ? undefined
315
- : typeof labels.phrasePlaceholder === "function"
316
- ? labels.phrasePlaceholder(phrase)
317
- : labels.phrasePlaceholder;
318
-
319
- const phraseLabel =
320
- phrase === undefined ? "" : typeof labels.phrase === "function" ? labels.phrase(phrase) : labels.phrase;
321
- const phraseFieldProps = {
322
- ref: firstFieldRef,
323
- value: typed,
324
- autoComplete: "off",
325
- autoCapitalize: "off",
326
- spellCheck: false,
327
- readOnly: busy,
328
- onChange: (e: ChangeEvent<HTMLInputElement>) => setTyped(e.target.value),
329
- };
640
+ // The first field takes focus on arm: the tick, then the phrase, then the password —
641
+ // the order they are drawn in.
642
+ const firstField = asksAcknowledge ? "acknowledge" : phrase !== undefined ? "phrase" : "password";
643
+ const lines = (consequences ?? []).map((item, index) =>
644
+ typeof item === "string"
645
+ ? { key: item, text: item as ReactNode, severe: false }
646
+ : { key: item.key ?? String(index), text: item.text, severe: Boolean(item.severe) },
647
+ );
330
648
 
331
649
  const toneText = tone === "warning" ? "text-[var(--warning)]" : "text-[var(--danger)]";
332
650
 
@@ -377,7 +695,7 @@ export function DangerConfirm({
377
695
  <div {...rest} className={cn("space-y-2", className)}>
378
696
  <form
379
697
  className="space-y-2"
380
- aria-describedby={promptId}
698
+ aria-describedby={lines.length > 0 ? `${promptId} ${consequencesId}` : promptId}
381
699
  aria-busy={busy || undefined}
382
700
  onSubmit={submit}
383
701
  noValidate
@@ -385,20 +703,43 @@ export function DangerConfirm({
385
703
  <p id={promptId} className={cn("text-xs font-medium", toneText)}>
386
704
  {prompt ?? labels.prompt}
387
705
  </p>
388
- {phrase !== undefined &&
389
- (phrasePlaceholder ? (
390
- // A static label above, so the placeholder has the empty field to itself.
391
- <div className="space-y-1.5">
392
- <Label htmlFor={phraseFieldId}>{phraseLabel}</Label>
393
- <Input id={phraseFieldId} placeholder={phrasePlaceholder} {...phraseFieldProps} />
394
- </div>
395
- ) : (
396
- <Input label={phraseLabel} {...phraseFieldProps} />
397
- ))}
706
+ {lines.length > 0 && (
707
+ <ul id={consequencesId} className="list-disc space-y-1 ps-5 text-xs text-[var(--text-secondary)]">
708
+ {lines.map((line) => (
709
+ <li key={line.key} className={line.severe ? "text-[var(--danger)]" : undefined}>
710
+ {line.text}
711
+ </li>
712
+ ))}
713
+ </ul>
714
+ )}
715
+ {asksAcknowledge && (
716
+ <Checkbox
717
+ ref={firstField === "acknowledge" ? firstFieldRef : undefined}
718
+ label={acknowledgeLabel}
719
+ checked={acknowledged}
720
+ // Not `disabled` while busy, for the same reason the fields are readOnly:
721
+ // the box may hold the focus. A change while busy is simply not taken.
722
+ onCheckedChange={(next) => {
723
+ if (!busy) setAcknowledged(next);
724
+ }}
725
+ />
726
+ )}
727
+ {phrase !== undefined && (
728
+ <TypedConfirmField
729
+ ref={firstField === "phrase" ? firstFieldRef : undefined}
730
+ target={phrase}
731
+ match={phraseMatch}
732
+ value={typed}
733
+ onValueChange={setTyped}
734
+ busy={busy}
735
+ // Already resolved through this tile's own `labels` prop; handed on so a
736
+ // per-instance phrase wording reaches the field.
737
+ labels={{ phrase: labels.phrase, phrasePlaceholder: labels.phrasePlaceholder }}
738
+ />
739
+ )}
398
740
  {requirePassword && (
399
741
  <CurrentPasswordInput
400
- // The phrase field takes the ref when both are there — it comes first.
401
- inputRef={phrase === undefined ? firstFieldRef : undefined}
742
+ ref={firstField === "password" ? firstFieldRef : undefined}
402
743
  label={labels.password}
403
744
  value={password}
404
745
  busy={busy}
@@ -413,12 +754,18 @@ export function DangerConfirm({
413
754
  type="submit"
414
755
  variant={tone === "warning" ? "primary" : "danger"}
415
756
  disabled={!canConfirm}
757
+ // Held by a guard, or armed and then locked (or rendered armed under a lock):
758
+ // the confirm says why the way Button does — focusable, `aria-disabled`, the
759
+ // reason in its tooltip and description, a press swallowed (the submit with
760
+ // it) — and the fields stay as typed. Only busy is the native `disabled`.
761
+ disabledReason={confirmReason}
416
762
  aria-busy={busy || undefined}
417
763
  >
418
764
  {busy && <Spinner label={null} className="h-4 w-4" />}
419
765
  {confirmLabel ?? labels.confirm}
420
766
  </Button>
421
767
  </div>
768
+ {locked && <p className="text-xs text-[var(--text-muted)]">{lockedReason}</p>}
422
769
  </form>
423
770
  </div>
424
771
  );