@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
@@ -0,0 +1,288 @@
1
+ import { forwardRef, useId, useState } from "react";
2
+ import type { ChangeEvent, ClipboardEvent, FocusEvent, InputHTMLAttributes, ReactNode } from "react";
3
+ import { cn } from "../lib/cn";
4
+ import { hasMessage, mergeDescribedBy } from "./choice-parts";
5
+ import { FIELD_BASE, FIELD_INVALID, FIELD_WRITABLE_LOOK, FLOATING_INPUT_CLASS, FloatingField } from "./ui";
6
+
7
+ /**
8
+ * The field for a verification code — the six digits an authenticator app shows, or the
9
+ * ones a sign-in mail or SMS carries.
10
+ *
11
+ * Asked for by kastlan (input audit, item 3) and keksdose (K8) for the sign-in
12
+ * verification step, and already written twice by hand inside {@link TwoFactorSetting}.
13
+ * The three copies agreed on `inputMode="numeric"` and `autoComplete="one-time-code"`
14
+ * and on nothing else: kastlan's login stripped non-digits and capped at six, keksdose's
15
+ * capped at eight and kept whatever was typed, and the settings card kept everything,
16
+ * spaces included — so "123 456" pasted from an authenticator that groups its digits
17
+ * reached the server with the space in it.
18
+ *
19
+ * ONE FIELD, NOT SIX BOXES. The six-box look is the fashionable one, and every part of
20
+ * it works against the two ways a code actually arrives. A paste lands in ONE input,
21
+ * so the boxes need a paste handler that spreads it; the platform's own code autofill
22
+ * (iOS and macOS offer the code from Messages and Mail above the keyboard, Android's
23
+ * keyboards do the same) fills the ONE field marked `one-time-code`, so the boxes need
24
+ * that field to exist anyway, hidden; and a screen reader hears six unlabelled inputs
25
+ * of one character each. One text field gets all three right for free.
26
+ *
27
+ * WHAT IT DOES TO THE TEXT:
28
+ * - **Digits only**, as a STRING. A code may start with 0, and a number field would
29
+ * drop it ("012345" → 12345) — kastlan's login says as much in a comment. Every
30
+ * character that is not a digit is dropped as it arrives, so the spaces and dashes
31
+ * of "123 456" and "123-456" go, whether typed or pasted. Full-width digits (what a
32
+ * Chinese input method types: "123") are folded to ASCII first (NFKC).
33
+ * - **No `maxLength` attribute.** The browser applies it BEFORE the change event, so
34
+ * pasting "123 456" into a `maxLength={6}` field kept "123 45" and the cleaning saw
35
+ * one digit short. The length is enforced here instead, after the cleaning: a field
36
+ * that is full refuses another digit, as `maxLength` would.
37
+ * - **A paste that is a whole code replaces the field**, wherever the caret is: a
38
+ * person pasting six digits means "this is the code", not "insert these into the
39
+ * stale one".
40
+ * - **The caret stays put** when a character is dropped, rather than jumping to the
41
+ * end as a controlled input does when its value is put back.
42
+ *
43
+ * `onComplete` fires when an edit — a keystroke, a paste, the platform's autofill —
44
+ * makes the code `length` digits long. It is a notification, nothing more: the field
45
+ * never submits a form on its own. Whether a full code verifies at once is the app's
46
+ * call (a sign-in step may; a "turn off two-factor" form that also wants a password
47
+ * should not). It does not fire for a value set from outside.
48
+ *
49
+ * `label`, `hint` and `error` read as on every kit field: a floating label, a text hint
50
+ * as a caption under the field (a {@link FieldHint} "?" rides the label line instead),
51
+ * the error under that, both attached through `aria-describedby` after the caller's own.
52
+ */
53
+ export interface OneTimeCodeInputProps
54
+ extends Omit<
55
+ InputHTMLAttributes<HTMLInputElement>,
56
+ "value" | "defaultValue" | "onChange" | "type" | "inputMode" | "autoComplete" | "maxLength" | "pattern"
57
+ > {
58
+ /** The digits so far — a string, so leading zeros survive. */
59
+ value: string;
60
+ /** The cleaned code after every edit: digits only, never longer than `length`. */
61
+ onChange: (code: string) => void;
62
+ /** How many digits the code has. Default 6. The field refuses digits past it. */
63
+ length?: number;
64
+ /** Called with the code when an edit makes it `length` digits long. Never submits
65
+ * anything itself. */
66
+ onComplete?: (code: string) => void;
67
+ /** The floating label, as on {@link Input}. Without one, name the field with
68
+ * `aria-label` or `aria-labelledby` (kastlan's login points it at its prompt). */
69
+ label?: ReactNode;
70
+ /** Plain text: a caption under the field, attached with `aria-describedby`. Anything
71
+ * else (a {@link FieldHint}): on the label line — or at the field's end edge when
72
+ * there is no label. The rule {@link Select}'s `hint` follows. */
73
+ hint?: ReactNode;
74
+ /** What is wrong ("That code has expired"). Rendered under the field, attached with
75
+ * `aria-describedby`, and implies `invalid`. */
76
+ error?: ReactNode;
77
+ /** Paint the field invalid and set `aria-invalid` without a message of its own. */
78
+ invalid?: boolean;
79
+ /** Classes for the `<input>`; `className` styles the field's wrapper once it has a
80
+ * label, the `<input>` itself when it has none — as on {@link Input}. */
81
+ inputClassName?: string;
82
+ /**
83
+ * Keep the field `readOnly` until it first takes focus.
84
+ *
85
+ * A browser that has a login saved for the site fills it in on load — and on a page
86
+ * where the code field is the only text field near a password field, it can take the
87
+ * code field for the username and write the address into it. A read-only field is
88
+ * never autofilled, and focus is the moment a person (or the platform's code
89
+ * suggestion, which needs a focused field anyway) starts to use it. So the field
90
+ * stays read-only until then, and only until then.
91
+ *
92
+ * It keeps the LOOK of an editable field throughout ({@link FIELD_WRITABLE_LOOK}):
93
+ * this is the one place where read-only does not mean "you may not type here", and
94
+ * without the opt-out the field would sit there greyed until it is clicked.
95
+ *
96
+ * {@link TwoFactorSetting} has always done this — its code fields share a page with
97
+ * the current-password field of the disable form — and keeps doing it through this
98
+ * prop. Off by default, because it is a trade: a field that is read-only at the
99
+ * moment focus lands can be announced as read-only by a screen reader, and on some
100
+ * mobile browsers it does not raise the keyboard until a second tap. Turn it on where
101
+ * a saved login actually lands in the field.
102
+ */
103
+ readOnlyUntilFocus?: boolean;
104
+ }
105
+
106
+ // The error line under a field — the type of `ui.tsx`'s (module-private) one, so this
107
+ // field's message is indistinguishable from an Input's.
108
+ const FIELD_ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
109
+ const FIELD_CAPTION_CLASS = "mt-1 text-[11px] leading-tight text-[var(--text-muted)]";
110
+
111
+ // Equal-width digits, spaced a little apart, so a code reads — and compares against the
112
+ // authenticator's screen — digit by digit.
113
+ const CODE_TYPE = "tabular-nums tracking-widest";
114
+
115
+ /** The ASCII digits in `text`, in order. NFKC folds full-width digits ("1") to "1". */
116
+ function digitsOf(text: string): string {
117
+ return text.normalize("NFKC").replace(/\D/g, "");
118
+ }
119
+
120
+ export const OneTimeCodeInput = forwardRef<HTMLInputElement, OneTimeCodeInputProps>(
121
+ function OneTimeCodeInput(props, ref) {
122
+ const {
123
+ value,
124
+ onChange,
125
+ length = 6,
126
+ onComplete,
127
+ label,
128
+ hint,
129
+ error,
130
+ invalid: invalidProp,
131
+ className,
132
+ inputClassName,
133
+ readOnlyUntilFocus = false,
134
+ readOnly,
135
+ id,
136
+ placeholder,
137
+ onFocus,
138
+ onPaste,
139
+ "aria-describedby": ariaDescribedBy,
140
+ "aria-invalid": ariaInvalid,
141
+ ...rest
142
+ } = props;
143
+ // A length below one is no code at all; a fractional one is a typo.
144
+ const size = Math.max(1, Math.floor(length) || 1);
145
+ const generatedId = useId();
146
+ const fieldId = id ?? generatedId;
147
+ const hintId = useId();
148
+ const errorId = useId();
149
+ // Text is a caption under the field; a FieldHint rides the label line. See `hint`.
150
+ const textHint = (typeof hint === "string" && hint !== "") || typeof hint === "number";
151
+ const hasError = hasMessage(error);
152
+ // Passed at all — even as `undefined` — the field keeps its box, so the `<input>` is
153
+ // never re-parented (and never loses focus) when a message comes or goes. See
154
+ // `FieldGroup` in ui.tsx.
155
+ const reserve = "error" in props || "hint" in props;
156
+ const describedBy = mergeDescribedBy(ariaDescribedBy, textHint && hintId, hasError && errorId);
157
+ const invalid = Boolean(invalidProp) || hasError || ariaInvalid === true || ariaInvalid === "true";
158
+ const labelled = label !== undefined;
159
+
160
+ // Focused once = in use; the guard is down for good from then on (see the prop).
161
+ const [focusedOnce, setFocusedOnce] = useState(false);
162
+ const guarding = readOnlyUntilFocus && !focusedOnce;
163
+
164
+ const commit = (next: string) => {
165
+ if (next === value) return;
166
+ onChange(next);
167
+ if (next.length === size) onComplete?.(next);
168
+ };
169
+
170
+ const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
171
+ const input = e.currentTarget;
172
+ const raw = input.value;
173
+ const cleaned = digitsOf(raw);
174
+ // Past the length: a full field refuses the digit (what `maxLength` does), a
175
+ // partial one keeps the first `size` — a paste longer than the room left.
176
+ const refused = cleaned.length > size && value.length >= size;
177
+ const next = refused ? value : cleaned.slice(0, size);
178
+ if (next !== raw) {
179
+ // Write the cleaned text back NOW, with the caret where the typist left it.
180
+ // React would otherwise put its value back after this handler, and setting an
181
+ // input's value moves the caret to the end — so a space typed mid-code threw
182
+ // the caret to the last digit. React finds the DOM already agreeing and leaves
183
+ // it alone.
184
+ const caret = input.selectionStart ?? raw.length;
185
+ const at = refused
186
+ ? Math.max(0, caret - (raw.length - value.length))
187
+ : Math.min(digitsOf(raw.slice(0, caret)).length, next.length);
188
+ input.value = next;
189
+ try {
190
+ input.setSelectionRange(at, at);
191
+ } catch {
192
+ /* not every input type has a selection; a text field does */
193
+ }
194
+ }
195
+ commit(next);
196
+ };
197
+
198
+ const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
199
+ onPaste?.(e);
200
+ // A read-only field still receives the event; it must not take the code.
201
+ if (e.defaultPrevented || readOnly || guarding || rest.disabled) return;
202
+ const pasted = digitsOf(e.clipboardData.getData("text"));
203
+ // Anything else — a few digits into a partial code — is an ordinary insertion,
204
+ // and the change handler cleans it.
205
+ if (pasted.length !== size) return;
206
+ e.preventDefault();
207
+ commit(pasted);
208
+ };
209
+
210
+ const handleFocus = (e: FocusEvent<HTMLInputElement>) => {
211
+ if (guarding) setFocusedOnce(true);
212
+ onFocus?.(e);
213
+ };
214
+
215
+ const input = (
216
+ <input
217
+ ref={ref}
218
+ id={labelled ? fieldId : id}
219
+ {...rest}
220
+ type="text"
221
+ inputMode="numeric"
222
+ autoComplete="one-time-code"
223
+ // The numeric keypad's older cue, for the browsers that predate `inputMode`.
224
+ // The value is digits only, so it can never fail the pattern.
225
+ pattern="[0-9]*"
226
+ // A labelled field's placeholder is a single space: it drives the floating
227
+ // label's `placeholder-shown` trick. Unlabelled, the caller's ("000000") shows.
228
+ placeholder={labelled ? " " : placeholder}
229
+ value={value}
230
+ readOnly={readOnly || guarding}
231
+ onChange={handleChange}
232
+ onPaste={handlePaste}
233
+ onFocus={handleFocus}
234
+ aria-invalid={invalid || undefined}
235
+ aria-describedby={describedBy}
236
+ className={cn(
237
+ labelled ? FLOATING_INPUT_CLASS : FIELD_BASE,
238
+ CODE_TYPE,
239
+ // Read-only for the guard's sake only: keep the editable look. A caller's
240
+ // own `readOnly` keeps FIELD_BASE's settled grey, as everywhere else.
241
+ guarding && !readOnly && FIELD_WRITABLE_LOOK,
242
+ !labelled && className,
243
+ inputClassName,
244
+ invalid && FIELD_INVALID,
245
+ )}
246
+ />
247
+ );
248
+
249
+ const nonTextHint = hasMessage(hint) && !textHint;
250
+ let field: ReactNode;
251
+ if (labelled) {
252
+ field = (
253
+ <FloatingField className={className} htmlFor={fieldId} label={label} hint={nonTextHint ? hint : undefined}>
254
+ {input}
255
+ </FloatingField>
256
+ );
257
+ } else if (nonTextHint) {
258
+ // No label line to ride: the "?" goes at the field's end edge, outside the box —
259
+ // where an unlabelled Select puts it.
260
+ field = (
261
+ <div className="flex items-center gap-1.5">
262
+ <div className="min-w-0 flex-1">{input}</div>
263
+ <span className="flex shrink-0 items-center">{hint}</span>
264
+ </div>
265
+ );
266
+ } else {
267
+ field = input;
268
+ }
269
+
270
+ if (!textHint && !hasError && !reserve) return field;
271
+ return (
272
+ <div>
273
+ {field}
274
+ {textHint && (
275
+ <p id={hintId} className={FIELD_CAPTION_CLASS}>
276
+ {hint}
277
+ </p>
278
+ )}
279
+ {hasError && (
280
+ <p id={errorId} className={FIELD_ERROR_CLASS}>
281
+ {error}
282
+ </p>
283
+ )}
284
+ </div>
285
+ );
286
+ },
287
+ );
288
+ OneTimeCodeInput.displayName = "OneTimeCodeInput";
@@ -0,0 +1,299 @@
1
+ import { forwardRef, useId, useState } from "react";
2
+ import type { ChangeEvent, FocusEvent } from "react";
3
+ import { useKitLabels } from "../i18n/kit-labels";
4
+ import { cn } from "../lib/cn";
5
+ import {
6
+ PHONE_COUNTRIES,
7
+ PHONE_DIAL_CODES,
8
+ formatNationalPhone,
9
+ formatPhone,
10
+ isE164,
11
+ parsePhone,
12
+ } from "../lib/phone";
13
+ import type { ParsedPhone, PhoneCountry, PhoneCountryCode } from "../lib/phone";
14
+ import { hasMessage, mergeDescribedBy } from "./choice-parts";
15
+ import { Input, Select, type InputProps } from "./ui";
16
+
17
+ // ── Labels ────────────────────────────────────────────────────────────────────
18
+
19
+ /** The words {@link PhoneInput} renders on its own behalf. */
20
+ export interface PhoneInputLabels {
21
+ /** The country-code select's accessible name. */
22
+ countryCode: string;
23
+ /** The select's last option: a number from a country not listed, typed with its
24
+ * own "+". */
25
+ other: string;
26
+ }
27
+
28
+ export const DEFAULT_PHONE_INPUT_LABELS: PhoneInputLabels = {
29
+ countryCode: "Country code",
30
+ other: "Other",
31
+ };
32
+
33
+ /**
34
+ * The `phoneInput` namespace: prop > `<UiKitProvider labels>` > English (0.22.0).
35
+ */
36
+ function usePhoneInputLabels(prop: Partial<PhoneInputLabels> | undefined): PhoneInputLabels {
37
+ const labels: PhoneInputLabels = useKitLabels("phoneInput", DEFAULT_PHONE_INPUT_LABELS, prop);
38
+ return labels;
39
+ }
40
+
41
+ // ── Helpers ───────────────────────────────────────────────────────────────────
42
+
43
+ /** The caption and the error under the row — the type every kit field uses for them
44
+ * (Select's caption, Input's `error`). */
45
+ const CAPTION_CLASS = "mt-1 text-[11px] leading-tight text-[var(--text-muted)]";
46
+ const ERROR_CLASS = "mt-1 text-[11px] leading-tight text-[var(--danger)]";
47
+
48
+ const isTextHint = (hint: unknown): hint is string | number =>
49
+ (typeof hint === "string" && hint !== "") || typeof hint === "number";
50
+
51
+ /** A stored value read back: an E.164 number these rules can read, else `null`. */
52
+ function storedNumber(value: string): ParsedPhone | null {
53
+ return isE164(value) ? parsePhone(value, "other") : null;
54
+ }
55
+
56
+ /** The select's country for a stored E.164 value, or `null` for free text. */
57
+ function countryOf(value: string): PhoneCountry | null {
58
+ const parsed = storedNumber(value);
59
+ return parsed ? (parsed.country ?? "other") : null;
60
+ }
61
+
62
+ /** What the field emits for typed text: E.164 when it reads, the text unchanged when
63
+ * it does not. */
64
+ function emitFor(raw: string, country: PhoneCountry): string {
65
+ return parsePhone(raw, country)?.e164 ?? raw;
66
+ }
67
+
68
+ /**
69
+ * The text a stored value shows, outside of typing. Under its own country: the national
70
+ * number grouped ("21 000 00 01" beside "CH +41"). Under another (a "+33" number while
71
+ * the select says "Other"): the whole international number. Free text: as it is.
72
+ */
73
+ function displayOf(value: string, country: PhoneCountry): string {
74
+ const parsed = storedNumber(value);
75
+ if (!parsed) return value;
76
+ if (parsed.country !== null && parsed.country === country) {
77
+ return formatNationalPhone(parsed.national, parsed.country);
78
+ }
79
+ return formatPhone(parsed.e164);
80
+ }
81
+
82
+ // ── Component ─────────────────────────────────────────────────────────────────
83
+
84
+ export interface PhoneInputProps extends Omit<InputProps, "type" | "value" | "defaultValue"> {
85
+ /** The number: E.164 ("+41210000001") for one the field could read, or free text —
86
+ * a value saved before the field existed is shown exactly as it is. `""` is no
87
+ * number. */
88
+ value: string;
89
+ /** On every change: E.164 when the typed text reads as a number of the selected
90
+ * country (or carries its own "+"), otherwise the typed text UNCHANGED. Nothing is
91
+ * refused — see {@link PhoneInput}. Plain `onChange` still fires with the raw event;
92
+ * bind this one to a form. */
93
+ onValueChange?: (value: string) => void;
94
+ /** The country a number typed without "+" belongs to, until the value says
95
+ * otherwise. Default `"CH"`. */
96
+ defaultCountry?: PhoneCountry;
97
+ /** The countries the select offers, in order; "Other" always follows. Default
98
+ * {@link PHONE_COUNTRIES} (CH, LI, DE, AT, FR, IT). A stored number from a country
99
+ * left out still shows under its own code. */
100
+ countries?: readonly PhoneCountryCode[];
101
+ /** See {@link PhoneInputLabels}. */
102
+ labels?: Partial<PhoneInputLabels>;
103
+ /** Classes for the whole field — the select, the input and the lines under them.
104
+ * `inputClassName` reaches the `<input>`. */
105
+ className?: string;
106
+ }
107
+
108
+ /**
109
+ * A phone number field (kastlan): a country-code select and the number, stored as
110
+ * E.164 and shown grouped.
111
+ *
112
+ * kastlan's contacts kept whatever was typed — "079 …", "+41 (0)79 …", "0041 79 …" —
113
+ * which nobody could dial from the app or match against the next import. This field
114
+ * turns all of those into one stored form, "+41210000001", without taking anything
115
+ * away from the person typing:
116
+ *
117
+ * - **A country to type against.** The select (default CH, then LI, DE, AT, FR, IT
118
+ * and "Other") says which country a number without "+" belongs to; the national
119
+ * trunk 0 may be typed or left out. Typing a "+" or "00" overrides it, and the
120
+ * select follows the number ("+49 …" switches it to DE). Changing the select moves
121
+ * the national number to the new country code.
122
+ * - **E.164 when it reads, the text when it does not.** A number the rules read is
123
+ * emitted as E.164; anything else ("ask reception", an extension) is emitted exactly
124
+ * as typed. Never refused, never an error of its own: a contact's number is often
125
+ * known only roughly, and a field that will not save "079 … (mornings)" makes
126
+ * people type it into the notes instead. An app that needs a dialable number
127
+ * checks {@link isE164} on submit.
128
+ * - **Existing values untouched.** A free-text value from before is shown exactly as
129
+ * it is, and normalised only when someone edits it — the field never rewrites a
130
+ * record just by being opened.
131
+ * - **Grouped when settled.** While typing the field shows what was typed; on blur a
132
+ * number it read is regrouped by its country ("21 000 00 01").
133
+ *
134
+ * **The limitation** — there is no libphonenumber behind this (≈150 kB of metadata in
135
+ * every bundle): the six countries have light rules (the trunk 0, a plausible length)
136
+ * and every other country is any "+" with 8–15 digits. A well-formed number that does
137
+ * not exist passes, and German and Austrian landlines, whose area codes vary in length,
138
+ * are grouped only where the split is known (mobile prefixes, the largest cities) and
139
+ * otherwise shown as one block. See `lib/phone.ts`.
140
+ *
141
+ * `label`, `hint`, `error` and `invalid` read as on {@link Input}: the label floats in
142
+ * the number's box, and a text hint and the error go under the whole row, attached to
143
+ * the number with `aria-describedby`. The select is named by `labels.countryCode`, and
144
+ * the pair is a group named by a string `label`.
145
+ */
146
+ export const PhoneInput = forwardRef<HTMLInputElement, PhoneInputProps>(function PhoneInput(
147
+ {
148
+ value,
149
+ onValueChange,
150
+ onChange,
151
+ onBlur,
152
+ defaultCountry = "CH",
153
+ countries = PHONE_COUNTRIES,
154
+ labels,
155
+ className,
156
+ inputClassName,
157
+ label,
158
+ hint,
159
+ error,
160
+ invalid,
161
+ disabled,
162
+ "aria-describedby": ariaDescribedBy,
163
+ ...rest
164
+ },
165
+ ref,
166
+ ) {
167
+ const text = usePhoneInputLabels(labels);
168
+ const [country, setCountry] = useState<PhoneCountry>(() => countryOf(value) ?? defaultCountry);
169
+ // What was typed, shown for as long as it still reads as the value (AmountInput's
170
+ // draft): the person's own spacing stays under their fingers, and is regrouped on
171
+ // blur.
172
+ const [draft, setDraft] = useState<string | null>(null);
173
+ // The last value this field emitted, to tell its own echo from a value set outside.
174
+ const [emitted, setEmitted] = useState<string | null>(null);
175
+ const [seen, setSeen] = useState(value);
176
+ if (seen !== value) {
177
+ setSeen(value);
178
+ if (value !== emitted) {
179
+ // Set from outside (a reset, another record): its own country, none of our text.
180
+ const own = countryOf(value);
181
+ if (own) setCountry(own);
182
+ setDraft(null);
183
+ }
184
+ }
185
+
186
+ const emit = (next: string) => {
187
+ setEmitted(next);
188
+ onValueChange?.(next);
189
+ };
190
+
191
+ const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
192
+ onChange?.(e);
193
+ const raw = e.target.value;
194
+ setDraft(raw);
195
+ const parsed = parsePhone(raw, country);
196
+ // A number typed with its own "+" takes the select with it.
197
+ if (parsed && parsed.country !== country) setCountry(parsed.country ?? "other");
198
+ emit(parsed?.e164 ?? raw);
199
+ };
200
+
201
+ const handleCountry = (next: PhoneCountry) => {
202
+ setCountry(next);
203
+ const stored = storedNumber(value);
204
+ if (stored) {
205
+ // "Other", the same country, or a generic number with its own "+": the number
206
+ // stays, only the way it is shown changes.
207
+ if (next === "other" || stored.country === null || stored.country === next) {
208
+ setDraft(null);
209
+ return;
210
+ }
211
+ // A different listed country: the national number moves to its code. If it does
212
+ // not read there, the digits are kept as text for the person to finish.
213
+ emit(parsePhone(stored.national, next)?.e164 ?? stored.national);
214
+ setDraft(null);
215
+ return;
216
+ }
217
+ // Free text (or a draft): read it again under the new country.
218
+ const source = draft ?? value;
219
+ const parsed = source.trim() === "" ? null : parsePhone(source, next);
220
+ if (parsed && parsed.e164 !== value) {
221
+ setDraft(source);
222
+ emit(parsed.e164);
223
+ }
224
+ };
225
+
226
+ const display = draft !== null && emitFor(draft, country) === value ? draft : displayOf(value, country);
227
+
228
+ // The select offers the configured countries, plus the stored number's own if it
229
+ // is not among them — a select cannot show a value it has no option for.
230
+ const options: PhoneCountryCode[] = [...countries];
231
+ if (country !== "other" && !options.includes(country)) options.push(country);
232
+
233
+ const captionId = useId();
234
+ const errorId = useId();
235
+ const caption = isTextHint(hint);
236
+ const showError = hasMessage(error);
237
+ const named = typeof label === "string" && label !== "";
238
+
239
+ return (
240
+ <div
241
+ className={className}
242
+ role={named ? "group" : undefined}
243
+ aria-label={named ? label : undefined}
244
+ >
245
+ <div className="flex items-stretch gap-2">
246
+ <Select
247
+ aria-label={text.countryCode}
248
+ value={country}
249
+ disabled={disabled}
250
+ onChange={(e) => handleCountry(e.target.value as PhoneCountry)}
251
+ className="w-28 shrink-0"
252
+ // Stretched to the number's height: a labelled field is taller than an
253
+ // unlabelled select, and the pair should read as one row.
254
+ selectClassName="h-full tabular-nums"
255
+ >
256
+ {options.map((code) => (
257
+ <option key={code} value={code}>
258
+ {`${code} +${PHONE_DIAL_CODES[code]}`}
259
+ </option>
260
+ ))}
261
+ <option value="other">{text.other}</option>
262
+ </Select>
263
+ {/* Its own flex item, so nothing Input wraps itself in can take the sizing. */}
264
+ <div className="min-w-0 flex-1">
265
+ <Input
266
+ ref={ref}
267
+ {...rest}
268
+ type="tel"
269
+ autoComplete={rest.autoComplete ?? "tel"}
270
+ label={label}
271
+ // A FieldHint "?" rides the label line; text goes under the whole row.
272
+ hint={caption ? undefined : hint}
273
+ invalid={invalid || showError}
274
+ aria-describedby={mergeDescribedBy(ariaDescribedBy, caption && captionId, showError && errorId)}
275
+ disabled={disabled}
276
+ value={display}
277
+ onChange={handleChange}
278
+ onBlur={(e: FocusEvent<HTMLInputElement>) => {
279
+ setDraft(null);
280
+ onBlur?.(e);
281
+ }}
282
+ inputClassName={cn("tabular-nums", inputClassName)}
283
+ />
284
+ </div>
285
+ </div>
286
+ {caption && (
287
+ <p id={captionId} className={CAPTION_CLASS}>
288
+ {hint}
289
+ </p>
290
+ )}
291
+ {showError && (
292
+ <p id={errorId} className={ERROR_CLASS}>
293
+ {error}
294
+ </p>
295
+ )}
296
+ </div>
297
+ );
298
+ });
299
+ PhoneInput.displayName = "PhoneInput";
@@ -2,10 +2,10 @@ import { useContext, useEffect, useId, useRef, useState } from "react";
2
2
  import type { KeyboardEvent, ReactNode } from "react";
3
3
 
4
4
  import { useKitLabels } from "../i18n/kit-labels";
5
- import { usePromisePending } from "./danger-confirm";
5
+ import { CurrentPasswordInput, usePromisePending } from "./danger-confirm";
6
6
  import { DialogFrame } from "./dialog-frame";
7
7
  import { ModalCloseContext } from "./modal";
8
- import { Button, Input, Spinner } from "./ui";
8
+ import { Button, Spinner } from "./ui";
9
9
 
10
10
  /**
11
11
  * Every string the dialog renders — the `reauthDialog` namespace of
@@ -85,8 +85,8 @@ export interface ReauthDialogProps {
85
85
  * `requirePassword` tile. Both are the same three facts — the field is the CURRENT
86
86
  * password (the password manager fills it, never generates one), a wrong one stays in
87
87
  * the dialog with its error, and the APP verifies it — so the promise handling is
88
- * `DangerConfirm`'s own, shared, the field is its `CurrentPasswordInput` spelled out
89
- * with an `error`, and this is the modal form of them.
88
+ * `DangerConfirm`'s own, shared, the field is its `CurrentPasswordInput` with an
89
+ * `error`, and this is the modal form of them.
90
90
  *
91
91
  * A component rather than a `useReauth()` promise: the step that follows the check
92
92
  * (Kurvenschmiede's WebAuthn ceremony) needs what the server answered, and the error
@@ -201,24 +201,23 @@ export function ReauthDialog({
201
201
  submit();
202
202
  }}
203
203
  >
204
- {/* CurrentPasswordInput's field — the password manager fills it with the CURRENT
205
- password and never offers to generate one; `readOnly`, not `disabled`, while
206
- busy, because disabling the field that has focus drops focus to <body> —
207
- plus `Input`'s own `error`, which that helper does not take. The message
208
- paints the field, sits under it and is its `aria-describedby`; passing the
209
- prop on every render keeps the field's box steady, so the message going away
210
- on the first keystroke of a retry no longer remounts the <input> under the
211
- typist (it did, which is why this used to draw its own message). */}
212
- <Input
204
+ {/* The kit's CurrentPasswordInput (0.23; it drew the same field by hand until
205
+ then): the password manager fills it with the CURRENT password and never
206
+ offers to generate one; `busy` makes it `readOnly`, not `disabled`, because
207
+ disabling the field that has focus drops focus to <body>. Labelled by this
208
+ namespace's `password` ("Current password"), not `dangerConfirm.password`.
209
+ The `error` is Input's own: it paints the field, sits under it and is its
210
+ `aria-describedby`; passing the prop on every render keeps the field's box
211
+ steady, so the message going away on the first keystroke of a retry does not
212
+ remount the <input> under the typist. */}
213
+ <CurrentPasswordInput
213
214
  ref={inputRef}
214
- type="password"
215
- autoComplete="current-password"
216
215
  label={labels.password}
217
216
  value={password}
218
- readOnly={busy}
217
+ busy={busy}
219
218
  error={shownError}
220
- onChange={(e) => {
221
- setPassword(e.target.value);
219
+ onValueChange={(next) => {
220
+ setPassword(next);
222
221
  setEdited(true);
223
222
  }}
224
223
  />