@eifi1/ui-kit 0.21.0 → 0.22.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 (278) hide show
  1. package/dist/chart.d.ts +10 -8
  2. package/dist/components/account-chips.d.ts +10 -3
  3. package/dist/components/account-chips.js +5 -2
  4. package/dist/components/account-chips.js.map +1 -1
  5. package/dist/components/account-settings.d.ts +33 -2
  6. package/dist/components/account-settings.js +43 -21
  7. package/dist/components/account-settings.js.map +1 -1
  8. package/dist/components/amount-input.d.ts +27 -8
  9. package/dist/components/amount-input.js +15 -28
  10. package/dist/components/amount-input.js.map +1 -1
  11. package/dist/components/autocomplete.d.ts +8 -0
  12. package/dist/components/autocomplete.js +9 -4
  13. package/dist/components/autocomplete.js.map +1 -1
  14. package/dist/components/button-group.d.ts +10 -8
  15. package/dist/components/calculator.d.ts +10 -8
  16. package/dist/components/checkbox-group.d.ts +104 -0
  17. package/dist/components/checkbox-group.js +111 -0
  18. package/dist/components/checkbox-group.js.map +1 -0
  19. package/dist/components/checkbox.d.ts +19 -0
  20. package/dist/components/checkbox.js +30 -7
  21. package/dist/components/checkbox.js.map +1 -1
  22. package/dist/components/chip.d.ts +18 -0
  23. package/dist/components/chip.js +47 -10
  24. package/dist/components/chip.js.map +1 -1
  25. package/dist/components/combobox.d.ts +13 -2
  26. package/dist/components/combobox.js +17 -8
  27. package/dist/components/combobox.js.map +1 -1
  28. package/dist/components/confirm-dialog.d.ts +66 -161
  29. package/dist/components/copy-button.d.ts +10 -8
  30. package/dist/components/country-select.d.ts +124 -0
  31. package/dist/components/country-select.js +237 -0
  32. package/dist/components/country-select.js.map +1 -0
  33. package/dist/components/currency-select.d.ts +17 -1
  34. package/dist/components/currency-select.js +56 -23
  35. package/dist/components/currency-select.js.map +1 -1
  36. package/dist/components/danger-confirm.d.ts +66 -166
  37. package/dist/components/danger-confirm.js +133 -49
  38. package/dist/components/danger-confirm.js.map +1 -1
  39. package/dist/components/data-table-cells.d.ts +1 -2
  40. package/dist/components/data-table-filter-popover.d.ts +1 -2
  41. package/dist/components/data-table-filters.d.ts +1 -2
  42. package/dist/components/data-table-labels.d.ts +4 -97
  43. package/dist/components/data-table-labels.js +9 -1
  44. package/dist/components/data-table-labels.js.map +1 -1
  45. package/dist/components/data-table-pagination.d.ts +3 -1
  46. package/dist/components/data-table.d.ts +1 -2
  47. package/dist/components/data-table.js +98 -3
  48. package/dist/components/data-table.js.map +1 -1
  49. package/dist/components/date-picker.d.ts +25 -5
  50. package/dist/components/date-picker.js +110 -59
  51. package/dist/components/date-picker.js.map +1 -1
  52. package/dist/components/entity-combobox.d.ts +8 -1
  53. package/dist/components/entity-combobox.js +9 -4
  54. package/dist/components/entity-combobox.js.map +1 -1
  55. package/dist/components/facing-pair.d.ts +10 -8
  56. package/dist/components/field-anatomy.d.ts +95 -0
  57. package/dist/components/field-anatomy.js +84 -0
  58. package/dist/components/field-anatomy.js.map +1 -0
  59. package/dist/components/field-parts.d.ts +114 -0
  60. package/dist/components/field-parts.js +72 -0
  61. package/dist/components/field-parts.js.map +1 -0
  62. package/dist/components/file-button.d.ts +10 -8
  63. package/dist/components/file-button.js +13 -3
  64. package/dist/components/file-button.js.map +1 -1
  65. package/dist/components/file-dropzone.d.ts +32 -9
  66. package/dist/components/file-dropzone.js +57 -34
  67. package/dist/components/file-dropzone.js.map +1 -1
  68. package/dist/components/form-actions.d.ts +10 -8
  69. package/dist/components/form-actions.js +61 -3
  70. package/dist/components/form-actions.js.map +1 -1
  71. package/dist/components/iban-input.d.ts +66 -0
  72. package/dist/components/iban-input.js +125 -0
  73. package/dist/components/iban-input.js.map +1 -0
  74. package/dist/components/icon-picker.d.ts +12 -2
  75. package/dist/components/icon-picker.js +9 -2
  76. package/dist/components/icon-picker.js.map +1 -1
  77. package/dist/components/inline-edit-field.d.ts +167 -0
  78. package/dist/components/inline-edit-field.js +293 -0
  79. package/dist/components/inline-edit-field.js.map +1 -0
  80. package/dist/components/language-select.d.ts +127 -0
  81. package/dist/components/language-select.js +18 -0
  82. package/dist/components/language-select.js.map +1 -0
  83. package/dist/components/line-items.d.ts +25 -3
  84. package/dist/components/line-items.js +55 -46
  85. package/dist/components/line-items.js.map +1 -1
  86. package/dist/components/list.d.ts +25 -0
  87. package/dist/components/list.js +16 -5
  88. package/dist/components/list.js.map +1 -1
  89. package/dist/components/money-field.d.ts +20 -8
  90. package/dist/components/money-field.js.map +1 -1
  91. package/dist/components/month-picker.d.ts +52 -4
  92. package/dist/components/month-picker.js +290 -86
  93. package/dist/components/month-picker.js.map +1 -1
  94. package/dist/components/multi-select.d.ts +8 -1
  95. package/dist/components/multi-select.js +9 -4
  96. package/dist/components/multi-select.js.map +1 -1
  97. package/dist/components/number-field.d.ts +25 -13
  98. package/dist/components/number-field.js +6 -20
  99. package/dist/components/number-field.js.map +1 -1
  100. package/dist/components/number-input.d.ts +70 -13
  101. package/dist/components/number-input.js +90 -38
  102. package/dist/components/number-input.js.map +1 -1
  103. package/dist/components/numpad-sheet.d.ts +10 -8
  104. package/dist/components/one-time-code-input.d.ts +100 -0
  105. package/dist/components/one-time-code-input.js +137 -0
  106. package/dist/components/one-time-code-input.js.map +1 -0
  107. package/dist/components/phone-input.d.ts +66 -0
  108. package/dist/components/phone-input.js +171 -0
  109. package/dist/components/phone-input.js.map +1 -0
  110. package/dist/components/series-chart.d.ts +10 -8
  111. package/dist/components/settings-fields.d.ts +10 -8
  112. package/dist/components/share-card.d.ts +8 -6
  113. package/dist/components/sign-chip.d.ts +66 -0
  114. package/dist/components/sign-chip.js +39 -0
  115. package/dist/components/sign-chip.js.map +1 -0
  116. package/dist/components/swatch-picker.d.ts +27 -2
  117. package/dist/components/swatch-picker.js +73 -31
  118. package/dist/components/swatch-picker.js.map +1 -1
  119. package/dist/components/switch.d.ts +19 -0
  120. package/dist/components/switch.js +24 -6
  121. package/dist/components/switch.js.map +1 -1
  122. package/dist/components/text-link.d.ts +10 -8
  123. package/dist/components/tile-radio.d.ts +68 -13
  124. package/dist/components/tile-radio.js +3 -3
  125. package/dist/components/tile-radio.js.map +1 -1
  126. package/dist/components/time-input.d.ts +15 -9
  127. package/dist/components/time-input.js.map +1 -1
  128. package/dist/components/toggle-group.d.ts +47 -1
  129. package/dist/components/toggle-group.js +110 -69
  130. package/dist/components/toggle-group.js.map +1 -1
  131. package/dist/components/ui.d.ts +10 -8
  132. package/dist/components/ui.js +280 -78
  133. package/dist/components/ui.js.map +1 -1
  134. package/dist/components/use-table-state.d.ts +1 -2
  135. package/dist/{data-table-3mi2R3so.d.ts → data-table-labels-B7OdnM0S.d.ts} +140 -3
  136. package/dist/data-table.d.ts +1 -2
  137. package/dist/data-table.js +2 -1
  138. package/dist/data-table.js.map +1 -1
  139. package/dist/feedback/feedback-attachment.d.ts +1 -1
  140. package/dist/feedback/feedback-attachment.js +210 -3
  141. package/dist/feedback/feedback-attachment.js.map +1 -1
  142. package/dist/feedback/feedback-dialog.d.ts +1 -1
  143. package/dist/feedback/feedback-dialog.js.map +1 -1
  144. package/dist/feedback/feedback-inbox.d.ts +1 -1
  145. package/dist/feedback/feedback-thread.d.ts +37 -3
  146. package/dist/feedback/feedback-thread.js +38 -9
  147. package/dist/feedback/feedback-thread.js.map +1 -1
  148. package/dist/{feedback-attachment-WFiXJ8We.d.ts → feedback-attachment-NhUm7Zga.d.ts} +70 -5
  149. package/dist/feedback.d.ts +2 -2
  150. package/dist/hooks/use-file-drop.d.ts +10 -8
  151. package/dist/i18n/defaults.d.ts +10 -8
  152. package/dist/i18n/defaults.js +17 -2
  153. package/dist/i18n/defaults.js.map +1 -1
  154. package/dist/i18n/german.d.ts +10 -8
  155. package/dist/i18n/german.js +50 -5
  156. package/dist/i18n/german.js.map +1 -1
  157. package/dist/i18n/kit-labels.d.ts +10 -8
  158. package/dist/i18n/kit-labels.js +8 -2
  159. package/dist/i18n/kit-labels.js.map +1 -1
  160. package/dist/i18n/languages.d.ts +10 -8
  161. package/dist/i18n/locales/de-CH.d.ts +10 -8
  162. package/dist/i18n/locales/en.d.ts +10 -8
  163. package/dist/i18n/locales/en.js +9 -0
  164. package/dist/i18n/locales/en.js.map +1 -1
  165. package/dist/i18n/locales/es.d.ts +10 -8
  166. package/dist/i18n/locales/es.js +48 -5
  167. package/dist/i18n/locales/es.js.map +1 -1
  168. package/dist/i18n/locales/fr.d.ts +10 -8
  169. package/dist/i18n/locales/fr.js +48 -5
  170. package/dist/i18n/locales/fr.js.map +1 -1
  171. package/dist/i18n/locales/hu.d.ts +10 -8
  172. package/dist/i18n/locales/hu.js +49 -5
  173. package/dist/i18n/locales/hu.js.map +1 -1
  174. package/dist/i18n/locales/it.d.ts +10 -8
  175. package/dist/i18n/locales/it.js +49 -5
  176. package/dist/i18n/locales/it.js.map +1 -1
  177. package/dist/i18n/locales/zh.d.ts +10 -8
  178. package/dist/i18n/locales/zh.js +48 -5
  179. package/dist/i18n/locales/zh.js.map +1 -1
  180. package/dist/i18n/review.d.ts +10 -8
  181. package/dist/i18n/review.js +8 -1
  182. package/dist/i18n/review.js.map +1 -1
  183. package/dist/index.d.ts +17 -10
  184. package/dist/index.js +57 -2
  185. package/dist/index.js.map +1 -1
  186. package/dist/{kit-labels-D1J2_jyD.d.ts → kit-labels-R8mIAc9N.d.ts} +811 -8
  187. package/dist/lib/countries.d.ts +43 -0
  188. package/dist/lib/countries.js +39 -0
  189. package/dist/lib/countries.js.map +1 -0
  190. package/dist/lib/decimal-marks.d.ts +46 -0
  191. package/dist/lib/decimal-marks.js +40 -0
  192. package/dist/lib/decimal-marks.js.map +1 -0
  193. package/dist/lib/iban.d.ts +98 -0
  194. package/dist/lib/iban.js +154 -0
  195. package/dist/lib/iban.js.map +1 -0
  196. package/dist/lib/isin.d.ts +30 -0
  197. package/dist/lib/isin.js +36 -0
  198. package/dist/lib/isin.js.map +1 -0
  199. package/dist/lib/phone.d.ts +69 -0
  200. package/dist/lib/phone.js +121 -0
  201. package/dist/lib/phone.js.map +1 -0
  202. package/dist/rhf/fields.d.ts +148 -11
  203. package/dist/rhf/fields.js +332 -3
  204. package/dist/rhf/fields.js.map +1 -1
  205. package/dist/rhf/form.d.ts +10 -8
  206. package/dist/rhf.d.ts +13 -9
  207. package/dist/rhf.js.map +1 -1
  208. package/dist/shell/app-shell.d.ts +10 -8
  209. package/dist/shell/top-bar-brand.d.ts +10 -8
  210. package/dist/shell.d.ts +10 -8
  211. package/dist/tour/tour.js +20 -24
  212. package/dist/tour/tour.js.map +1 -1
  213. package/dist/wizard/stepper-nav.d.ts +10 -8
  214. package/dist/wizard.d.ts +10 -8
  215. package/package.json +1 -1
  216. package/src/components/account-chips.tsx +17 -3
  217. package/src/components/account-settings.tsx +92 -26
  218. package/src/components/amount-input.tsx +49 -56
  219. package/src/components/autocomplete.tsx +133 -110
  220. package/src/components/checkbox-group.tsx +229 -0
  221. package/src/components/checkbox.tsx +72 -8
  222. package/src/components/chip.tsx +80 -10
  223. package/src/components/combobox.tsx +292 -253
  224. package/src/components/country-select.tsx +443 -0
  225. package/src/components/currency-select.tsx +109 -33
  226. package/src/components/danger-confirm.tsx +345 -77
  227. package/src/components/data-table-labels.ts +16 -1
  228. package/src/components/data-table.tsx +175 -6
  229. package/src/components/date-picker.tsx +173 -62
  230. package/src/components/entity-combobox.tsx +107 -87
  231. package/src/components/field-anatomy.tsx +190 -0
  232. package/src/components/field-parts.tsx +184 -0
  233. package/src/components/file-button.tsx +36 -3
  234. package/src/components/file-dropzone.tsx +106 -38
  235. package/src/components/form-actions.tsx +115 -2
  236. package/src/components/iban-input.tsx +231 -0
  237. package/src/components/icon-picker.tsx +26 -4
  238. package/src/components/inline-edit-field.tsx +533 -0
  239. package/src/components/language-select.tsx +78 -0
  240. package/src/components/line-items.tsx +35 -2
  241. package/src/components/list.tsx +55 -6
  242. package/src/components/money-field.tsx +10 -0
  243. package/src/components/month-picker.tsx +399 -63
  244. package/src/components/multi-select.tsx +80 -58
  245. package/src/components/number-field.tsx +28 -46
  246. package/src/components/number-input.tsx +210 -43
  247. package/src/components/one-time-code-input.tsx +288 -0
  248. package/src/components/phone-input.tsx +299 -0
  249. package/src/components/sign-chip.tsx +103 -0
  250. package/src/components/swatch-picker.tsx +118 -30
  251. package/src/components/switch.tsx +58 -7
  252. package/src/components/tile-radio.tsx +71 -16
  253. package/src/components/time-input.tsx +8 -1
  254. package/src/components/toggle-group.tsx +206 -73
  255. package/src/components/ui.tsx +472 -105
  256. package/src/data-table.ts +2 -1
  257. package/src/feedback/feedback-attachment.tsx +313 -9
  258. package/src/feedback/feedback-dialog.tsx +2 -0
  259. package/src/feedback/feedback-thread.tsx +66 -5
  260. package/src/i18n/defaults.ts +16 -1
  261. package/src/i18n/german.ts +47 -0
  262. package/src/i18n/kit-labels.tsx +78 -2
  263. package/src/i18n/locales/en.ts +10 -0
  264. package/src/i18n/locales/es.ts +46 -0
  265. package/src/i18n/locales/fr.ts +45 -0
  266. package/src/i18n/locales/hu.ts +46 -0
  267. package/src/i18n/locales/it.ts +47 -0
  268. package/src/i18n/locales/zh.ts +44 -0
  269. package/src/i18n/review.ts +7 -0
  270. package/src/index.ts +53 -3
  271. package/src/lib/countries.ts +91 -0
  272. package/src/lib/decimal-marks.ts +99 -0
  273. package/src/lib/iban.ts +171 -0
  274. package/src/lib/isin.ts +62 -0
  275. package/src/lib/phone.ts +211 -0
  276. package/src/rhf/fields.tsx +606 -9
  277. package/src/rhf.ts +2 -1
  278. package/src/tour/tour.tsx +66 -28
@@ -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
+
84
+ /**
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
+
70
112
  /**
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>.
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,9 @@ 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;
139
327
  }
140
328
 
141
329
  /** `satisfies` rather than a type annotation, so `DEFAULT_DANGER_CONFIRM_LABELS.phrase`
@@ -147,8 +335,23 @@ export const DEFAULT_DANGER_CONFIRM_LABELS = {
147
335
  prompt: "This cannot be undone.",
148
336
  password: "Password",
149
337
  phrase: (phrase: string) => `Type “${phrase}” to confirm`,
338
+ acknowledge: "I have read what this does and want to continue.",
150
339
  } satisfies DangerConfirmLabels;
151
340
 
341
+ /**
342
+ * One line of {@link DangerConfirmProps.consequences}: what the action will do. A plain
343
+ * string, or this, for a key of its own and the `severe` mark.
344
+ */
345
+ export interface DangerConsequence {
346
+ /** React key — keksdose keys its lines by what they say ("api_tokens", "only_door").
347
+ * Default: the text when it is a string, else the position. */
348
+ key?: string;
349
+ text: ReactNode;
350
+ /** "There is no way back" — painted in the danger colour rather than the list's
351
+ * muted one. keksdose reserves it for the one line that loses something for good. */
352
+ severe?: boolean;
353
+ }
354
+
152
355
  export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">, "onChange"> {
153
356
  /**
154
357
  * Runs the action, with the entered password when `requirePassword` is on.
@@ -161,6 +364,28 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
161
364
  onConfirm: (password?: string) => void | Promise<unknown>;
162
365
  /** Show a password field; confirm stays disabled until it is filled. */
163
366
  requirePassword?: boolean;
367
+ /**
368
+ * Show an "I understand" checkbox; confirm stays disabled until it is ticked
369
+ * (keksdose K7). `true` words it with `labels.acknowledge`; any other node IS the
370
+ * checkbox's label ("I understand Anna will be signed out everywhere").
371
+ *
372
+ * The guard for an action whose risk is MIS-TARGETING less than carelessness:
373
+ * keksdose's admin password reset asks for a typed address when the account has an
374
+ * encrypted door to lose, and a tick otherwise (`UserActionConfirm`), and its plan
375
+ * editor asks for the tick alone (`UserPlanEditor`). Both were hand-built beside this
376
+ * tile because it had no tick. It combines with `phrase` and `requirePassword`: every
377
+ * guard given must be satisfied.
378
+ */
379
+ requireAcknowledge?: ReactNode;
380
+ /**
381
+ * What the action will do, said before it happens — a list under the prompt, shown
382
+ * once armed (keksdose K7, `UserActionConfirm`'s consequences). Strings, or
383
+ * {@link DangerConsequence}s for a `severe` line in the danger colour.
384
+ *
385
+ * Derive them from the row's own facts rather than reciting the worst case: an admin
386
+ * who reads the same paragraph on every row stops reading it (keksdose dev#488).
387
+ */
388
+ consequences?: readonly (string | DangerConsequence)[];
164
389
  /** Show a "type <phrase> to confirm" field; confirm stays disabled until the field
165
390
  * matches (case-sensitive; surrounding spaces ignored unless `phraseMatch="exact"`). */
166
391
  phrase?: string;
@@ -195,9 +420,20 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
195
420
  * stays focusable (`aria-disabled`) so a keyboard user can land on it and hear why.
196
421
  *
197
422
  * 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.
423
+ * for a password for a write that can never land is the worse of the two. A tile that
424
+ * is ALREADY armed when the reason arrives (controlled `armed`, or a lock that landed
425
+ * while it was open) keeps its fields but its confirm takes the reason the same way
426
+ * — focusable, `aria-disabled`, the reason in its tooltip — and Enter does nothing.
199
427
  */
200
428
  lockedReason?: ReactNode;
429
+ /**
430
+ * This tile COMMITS — the 0.18 write-lock opt-in that {@link Button} has (keksdose
431
+ * K3). Under a locked {@link WriteLockProvider} the lock's reason becomes
432
+ * `lockedReason` (and wins over one of the tile's own), so a page that mounts the
433
+ * provider no longer threads `lockedReason={lock.locked ? lock.reason : undefined}`
434
+ * into every tile. No provider, or an unlocked one: no effect.
435
+ */
436
+ commit?: boolean;
201
437
  /** Controlled armed state. The parent can then collapse the tile from a mutation's
202
438
  * own `onSuccess` without returning a promise. */
203
439
  armed?: boolean;
@@ -209,8 +445,9 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
209
445
 
210
446
  /**
211
447
  * 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.
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.
214
451
  *
215
452
  * Keksdose hand-rolled it three times (load demo data, wipe everything, reset a
216
453
  * budget) and then as `shared/components/danger-confirm.tsx`; the only app-specific
@@ -225,6 +462,8 @@ export interface DangerConfirmProps extends Omit<ComponentPropsWithoutRef<"div">
225
462
  export function DangerConfirm({
226
463
  onConfirm,
227
464
  requirePassword,
465
+ requireAcknowledge,
466
+ consequences,
228
467
  phrase,
229
468
  phraseMatch = "trim",
230
469
  prompt,
@@ -233,7 +472,8 @@ export function DangerConfirm({
233
472
  confirmLabel,
234
473
  busy: busyProp,
235
474
  disabled,
236
- lockedReason,
475
+ lockedReason: ownLockedReason,
476
+ commit,
237
477
  armed: armedProp,
238
478
  onArmedChange,
239
479
  labels: labelsProp,
@@ -241,17 +481,19 @@ export function DangerConfirm({
241
481
  ...rest
242
482
  }: DangerConfirmProps) {
243
483
  const labels = useKitLabels("dangerConfirm", DEFAULT_DANGER_CONFIRM_LABELS, labelsProp);
484
+ const lockedReason = useCommitReason(commit, ownLockedReason);
244
485
  const [armedState, setArmedState] = useState(false);
245
486
  const armed = armedProp ?? armedState;
246
487
  const [password, setPassword] = useState("");
247
488
  const [typed, setTyped] = useState("");
489
+ const [acknowledged, setAcknowledged] = useState(false);
248
490
  const { pending, run } = usePromisePending();
249
491
  const busy = Boolean(busyProp) || pending;
250
492
  // `readOnly` rather than `disabled` on the fields while busy: disabling the field
251
493
  // that has focus (Enter was pressed in it) drops focus to <body>.
252
494
  const promptId = useId();
495
+ const consequencesId = useId();
253
496
  const reasonId = useId();
254
- const phraseFieldId = useId();
255
497
 
256
498
  // The kit's Button takes no ref, so the two buttons focus is moved to are found by id.
257
499
  const armId = useId();
@@ -270,6 +512,7 @@ export function DangerConfirm({
270
512
  if (!armed) {
271
513
  setPassword("");
272
514
  setTyped("");
515
+ setAcknowledged(false);
273
516
  }
274
517
  }
275
518
 
@@ -296,37 +539,34 @@ export function DangerConfirm({
296
539
  onArmedChange?.(next);
297
540
  };
298
541
 
299
- const locked = lockedReason !== undefined && lockedReason !== null && lockedReason !== false && lockedReason !== "";
542
+ const locked = hasContent(lockedReason);
543
+ // `true` words the tick with the namespace; any other node is its label; `false`,
544
+ // `null` and `""` are no tick at all.
545
+ const acknowledgeLabel: ReactNode =
546
+ requireAcknowledge === true ? labels.acknowledge : hasContent(requireAcknowledge) ? requireAcknowledge : null;
547
+ const asksAcknowledge = acknowledgeLabel !== null;
300
548
  const passwordOk = !requirePassword || password !== "";
301
549
  const phraseOk = phrase === undefined || typedMatches(typed, phrase, phraseMatch);
302
- const canConfirm = passwordOk && phraseOk && !busy;
550
+ const acknowledgeOk = !asksAcknowledge || acknowledged;
551
+ const canConfirm = passwordOk && phraseOk && acknowledgeOk && !busy;
303
552
 
304
553
  const submit = (e: FormEvent) => {
305
554
  e.preventDefault();
306
- if (!canConfirm) return;
555
+ // `locked` too: Enter in a field submits the form without the button's say.
556
+ if (!canConfirm || locked) return;
307
557
  // Disarms when it resolves; a rejection leaves it armed, fields kept: the caller
308
558
  // shows why, the user retries.
309
559
  run(onConfirm(requirePassword ? password : undefined), () => setArmed(false));
310
560
  };
311
561
 
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
- };
562
+ // The first field takes focus on arm: the tick, then the phrase, then the password —
563
+ // the order they are drawn in.
564
+ const firstField = asksAcknowledge ? "acknowledge" : phrase !== undefined ? "phrase" : "password";
565
+ const lines = (consequences ?? []).map((item, index) =>
566
+ typeof item === "string"
567
+ ? { key: item, text: item as ReactNode, severe: false }
568
+ : { key: item.key ?? String(index), text: item.text, severe: Boolean(item.severe) },
569
+ );
330
570
 
331
571
  const toneText = tone === "warning" ? "text-[var(--warning)]" : "text-[var(--danger)]";
332
572
 
@@ -377,7 +617,7 @@ export function DangerConfirm({
377
617
  <div {...rest} className={cn("space-y-2", className)}>
378
618
  <form
379
619
  className="space-y-2"
380
- aria-describedby={promptId}
620
+ aria-describedby={lines.length > 0 ? `${promptId} ${consequencesId}` : promptId}
381
621
  aria-busy={busy || undefined}
382
622
  onSubmit={submit}
383
623
  noValidate
@@ -385,20 +625,43 @@ export function DangerConfirm({
385
625
  <p id={promptId} className={cn("text-xs font-medium", toneText)}>
386
626
  {prompt ?? labels.prompt}
387
627
  </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
- ))}
628
+ {lines.length > 0 && (
629
+ <ul id={consequencesId} className="list-disc space-y-1 ps-5 text-xs text-[var(--text-secondary)]">
630
+ {lines.map((line) => (
631
+ <li key={line.key} className={line.severe ? "text-[var(--danger)]" : undefined}>
632
+ {line.text}
633
+ </li>
634
+ ))}
635
+ </ul>
636
+ )}
637
+ {asksAcknowledge && (
638
+ <Checkbox
639
+ ref={firstField === "acknowledge" ? firstFieldRef : undefined}
640
+ label={acknowledgeLabel}
641
+ checked={acknowledged}
642
+ // Not `disabled` while busy, for the same reason the fields are readOnly:
643
+ // the box may hold the focus. A change while busy is simply not taken.
644
+ onCheckedChange={(next) => {
645
+ if (!busy) setAcknowledged(next);
646
+ }}
647
+ />
648
+ )}
649
+ {phrase !== undefined && (
650
+ <TypedConfirmField
651
+ ref={firstField === "phrase" ? firstFieldRef : undefined}
652
+ target={phrase}
653
+ match={phraseMatch}
654
+ value={typed}
655
+ onValueChange={setTyped}
656
+ busy={busy}
657
+ // Already resolved through this tile's own `labels` prop; handed on so a
658
+ // per-instance phrase wording reaches the field.
659
+ labels={{ phrase: labels.phrase, phrasePlaceholder: labels.phrasePlaceholder }}
660
+ />
661
+ )}
398
662
  {requirePassword && (
399
663
  <CurrentPasswordInput
400
- // The phrase field takes the ref when both are there — it comes first.
401
- inputRef={phrase === undefined ? firstFieldRef : undefined}
664
+ ref={firstField === "password" ? firstFieldRef : undefined}
402
665
  label={labels.password}
403
666
  value={password}
404
667
  busy={busy}
@@ -413,12 +676,17 @@ export function DangerConfirm({
413
676
  type="submit"
414
677
  variant={tone === "warning" ? "primary" : "danger"}
415
678
  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}
416
683
  aria-busy={busy || undefined}
417
684
  >
418
685
  {busy && <Spinner label={null} className="h-4 w-4" />}
419
686
  {confirmLabel ?? labels.confirm}
420
687
  </Button>
421
688
  </div>
689
+ {locked && <p className="text-xs text-[var(--text-muted)]">{lockedReason}</p>}
422
690
  </form>
423
691
  </div>
424
692
  );
@@ -3,7 +3,14 @@
3
3
  // ships no translations; the English defaults below make the table usable with no
4
4
  // wiring.
5
5
 
6
- export interface DataTableLabels {
6
+ // A type-only import: erased at build time, so this module stays free of the table
7
+ // itself (and of react-router), which `i18n/defaults` relies on.
8
+ import type { DataTableSortLabels } from "./data-table";
9
+
10
+ /** The phone sort control's four keys (`sortBy`, `sortDefault`, `sortAscending`,
11
+ * `sortDescending`, 0.22.0) are {@link DataTableSortLabels}', declared beside the
12
+ * control and part of this namespace like every other key. */
13
+ export interface DataTableLabels extends DataTableSortLabels {
7
14
  // Table chrome
8
15
  columns: string;
9
16
  selectAllRows: string;
@@ -143,6 +150,14 @@ export const DEFAULT_DATA_TABLE_LABELS: DataTableLabels = {
143
150
  booleanTrue: "Yes",
144
151
  booleanFalse: "No",
145
152
  booleanUnset: "Not set",
153
+ // The phone sort control (0.22.0). The same words as `DEFAULT_DATA_TABLE_SORT_LABELS`
154
+ // in data-table.tsx, written out rather than spread in: importing that VALUE here
155
+ // would make data-table.tsx and this module import each other (data-table.tsx reads
156
+ // its resolver from here), and would pull the whole table into `i18n/defaults`.
157
+ sortBy: "Sort by",
158
+ sortDefault: "Default order",
159
+ sortAscending: "Ascending",
160
+ sortDescending: "Descending",
146
161
  };
147
162
 
148
163
  /**