@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,6 +1,6 @@
1
1
  import { createContext, forwardRef, useContext, useId, useLayoutEffect, useRef, useState } from "react";
2
2
  import { ChevronDown, Eye, EyeOff, HelpCircle, Plus, X } from "lucide-react";
3
- import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ComponentPropsWithoutRef, CSSProperties, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
3
+ import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ChangeEvent, ComponentPropsWithoutRef, CSSProperties, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactElement, ReactNode, Ref, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { scrollIntoStrip, useStripFade } from "../lib/strip-fade";
6
6
  import { horizontalStep } from "../lib/direction";
@@ -10,6 +10,22 @@ import { DEFAULT_COMMON_LABELS, useKitLabels, useKitLink } from "../i18n/kit-lab
10
10
  import type { KitLinkComponent, KitLinkProps } from "../i18n/kit-labels";
11
11
  import { pickLinkRenderer, replacingClick, routerLinkNavigation } from "./text-link";
12
12
  import { useCommitReason } from "./write-lock";
13
+ import { mergeDescribedBy } from "./choice-parts";
14
+ import {
15
+ EndHintRow,
16
+ FieldCaption,
17
+ LockedReason,
18
+ useFieldHint,
19
+ useLockReason,
20
+ FLOATING_LABEL_STATIC,
21
+ STATIC_LABEL_TYPE,
22
+ type FieldHintParts,
23
+ } from "./field-parts";
24
+
25
+ // The static label lives in field-parts since 0.23 (it is what the field anatomy
26
+ // there draws, and field-parts must not import this file); public from here as before.
27
+ export { FieldLabel, FLOATING_LABEL_STATIC } from "./field-parts";
28
+ export type { FieldLabelProps } from "./field-parts";
13
29
 
14
30
  export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand" | "link";
15
31
 
@@ -1474,22 +1490,6 @@ const FLOATING_ROW_CLASS = cn(
1474
1490
  "peer-disabled:opacity-50",
1475
1491
  );
1476
1492
 
1477
- // The TYPE of the small static label, without any placement. Split out so the
1478
- // label and anything sharing its line (see `hint` on {@link FloatingField}) are
1479
- // laid out by one flex row instead of by two absolute offsets guessing at the
1480
- // same baseline — which is what put dev#468's "?" three pixels above the word it
1481
- // belongs to.
1482
- const STATIC_LABEL_TYPE =
1483
- "text-[11px] leading-tight text-[var(--text-muted)] peer-disabled:opacity-50";
1484
-
1485
- // A field that always has a value (select / dropdown trigger) keeps the label
1486
- // permanently in the floated position — small, in the top strip, value below.
1487
- export const FLOATING_LABEL_STATIC = cn(
1488
- "pointer-events-none absolute start-3 top-1",
1489
- STATIC_LABEL_TYPE,
1490
- "max-w-[calc(100%-1.5rem)] truncate",
1491
- );
1492
-
1493
1493
  /**
1494
1494
  * The one place that assembles a labelled field: a `relative` wrapper around the
1495
1495
  * control (`children`) plus a floating label inside the top strip. Pass `staticLabel`
@@ -1641,23 +1641,6 @@ export function FieldHint({
1641
1641
  );
1642
1642
  }
1643
1643
 
1644
- /**
1645
- * The floating label for a custom-dropdown trigger (a `<span>`, since the trigger is a
1646
- * button not a labelable input). Same placement as {@link FloatingField}'s static label,
1647
- * so every labelled field lines up. Render inside a `relative` wrapper, before the trigger.
1648
- */
1649
- export interface FieldLabelProps extends ComponentPropsWithoutRef<"span"> {
1650
- children: ReactNode;
1651
- }
1652
-
1653
- export function FieldLabel({ children, className, ...rest }: FieldLabelProps) {
1654
- return (
1655
- <span {...rest} className={cn(FLOATING_LABEL_STATIC, "z-10", className)}>
1656
- {children}
1657
- </span>
1658
- );
1659
- }
1660
-
1661
1644
  export interface LabelProps extends ComponentPropsWithoutRef<"label"> {
1662
1645
  /** Draw the required mark after the text. The mark is `aria-hidden`: what a
1663
1646
  * screen reader hears is the control's own `required` / `aria-required`, which
@@ -1840,6 +1823,149 @@ export function resolvePasswordRevealLabels(
1840
1823
  return { ...DEFAULT_PASSWORD_REVEAL_LABELS, ...partial };
1841
1824
  }
1842
1825
 
1826
+ /**
1827
+ * What the character counter of {@link Input} and {@link Textarea} (`showCount`) says.
1828
+ * The visible "12/80" is digits and a slash and is not translated; these are the words
1829
+ * a screen reader gets instead of it.
1830
+ */
1831
+ export interface CharacterCountLabels {
1832
+ /** The counter as part of the field's description, read when focus reaches the
1833
+ * field: "12 of 80 characters". */
1834
+ count: (used: number, max: number) => string;
1835
+ /** Said once, politely, on entering the last stretch before the limit: "8
1836
+ * characters left". */
1837
+ remaining: (left: number) => string;
1838
+ /** Said once, politely, on reaching the limit. */
1839
+ limitReached: string;
1840
+ }
1841
+
1842
+ export const DEFAULT_CHARACTER_COUNT_LABELS: CharacterCountLabels = {
1843
+ count: (used, max) => `${used} of ${max} characters`,
1844
+ remaining: (left) => (left === 1 ? "1 character left" : `${left} characters left`),
1845
+ limitReached: "Character limit reached",
1846
+ };
1847
+
1848
+ /** The length the browser's own `maxLength` counts — UTF-16 code units — so the counter
1849
+ * and the limit can never disagree about whether a field is full. */
1850
+ function textLength(value: unknown): number {
1851
+ return value === undefined || value === null ? 0 : String(value).length;
1852
+ }
1853
+
1854
+ /** The counter's length: the controlled `value`'s, else what the field has been typed
1855
+ * to (from `defaultValue` on). `track` is called from the field's change handler. */
1856
+ function useTypedLength(value: unknown, defaultValue: unknown) {
1857
+ const [typed, setTyped] = useState(() => textLength(defaultValue));
1858
+ return { used: value !== undefined ? textLength(value) : typed, track: (text: string) => setTyped(text.length) };
1859
+ }
1860
+
1861
+ type CountZone = "ok" | "near" | "limit";
1862
+
1863
+ /** The last stretch before the limit: a tenth of it, at least 1 and at most 20 — "8
1864
+ * left" of 80, and 20 of 2,000 rather than 200, which is not "near" in any sense a
1865
+ * person typing means. */
1866
+ function nearLimitAt(max: number): number {
1867
+ return Math.min(20, Math.max(1, Math.ceil(max * 0.1)));
1868
+ }
1869
+
1870
+ function countZone(used: number, max: number): CountZone {
1871
+ if (used >= max) return "limit";
1872
+ return max - used <= nearLimitAt(max) ? "near" : "ok";
1873
+ }
1874
+
1875
+ /**
1876
+ * The live "12/80" under a field with `showCount` (keksdose K11: the admin broadcast,
1877
+ * support replies, canned replies and the handoff note each counted by hand, and one
1878
+ * announced every keystroke).
1879
+ *
1880
+ * Three channels, each saying only what it should:
1881
+ * - the digits, `aria-hidden` — "12 slash 80" is not a sentence;
1882
+ * - a `hidden` "12 of 80 characters" on the field's `aria-describedby`, so the count is
1883
+ * read with the field when focus reaches it, and never while typing;
1884
+ * - a polite live region that speaks ONCE per stretch: on entering the last tenth
1885
+ * ("8 characters left") and on reaching the limit. A region that changed on every
1886
+ * keystroke would talk over the user's own typing echo for the whole message.
1887
+ * Nothing is said on mount: a stored note already near its limit is not news.
1888
+ */
1889
+ function CharacterCount({
1890
+ id,
1891
+ used,
1892
+ max,
1893
+ labels,
1894
+ caption,
1895
+ }: {
1896
+ id: string;
1897
+ used: number;
1898
+ max: number;
1899
+ labels: CharacterCountLabels;
1900
+ caption: ReactNode;
1901
+ }) {
1902
+ const zone = countZone(used, max);
1903
+ // What the live region holds, re-set only when the stretch changes — the previous
1904
+ // render's zone kept in state, the pattern React documents for "adjusting state when
1905
+ // a prop changes", so the region's text (and so the announcement) changes at most
1906
+ // once per crossing.
1907
+ const [said, setSaid] = useState<{ zone: CountZone; text: string }>({ zone, text: "" });
1908
+ if (said.zone !== zone) {
1909
+ setSaid({
1910
+ zone,
1911
+ text: zone === "near" ? labels.remaining(max - used) : zone === "limit" ? labels.limitReached : "",
1912
+ });
1913
+ }
1914
+ return (
1915
+ // `relative` holds the sr-only region (see sr-only-containment.test).
1916
+ <div className="relative mt-1 flex items-start gap-2">
1917
+ <div className="min-w-0 flex-1">{caption}</div>
1918
+ <span
1919
+ aria-hidden
1920
+ className={cn(
1921
+ "shrink-0 text-[11px] leading-tight tabular-nums",
1922
+ zone === "limit"
1923
+ ? "text-[var(--danger)]"
1924
+ : zone === "near"
1925
+ ? "text-[var(--warning)]"
1926
+ : "text-[var(--text-muted)]",
1927
+ )}
1928
+ >
1929
+ {used}/{max}
1930
+ </span>
1931
+ <span id={id} hidden>
1932
+ {labels.count(used, max)}
1933
+ </span>
1934
+ <span role="status" className="sr-only">
1935
+ {said.text}
1936
+ </span>
1937
+ </div>
1938
+ );
1939
+ }
1940
+
1941
+ /** What goes under a text field: the caption (beside the counter, when there is one),
1942
+ * then the error. `null` when there is nothing, so a field with none keeps exactly the
1943
+ * DOM it had (see {@link FieldGroup}). */
1944
+ function fieldBelow(hint: FieldHintParts, counter: ReactNode, errorEl: ReactNode): ReactNode {
1945
+ if (counter === null && hint.captionText === undefined && errorEl === null) return null;
1946
+ return (
1947
+ <>
1948
+ {counter ?? <FieldCaption parts={hint} />}
1949
+ {errorEl}
1950
+ </>
1951
+ );
1952
+ }
1953
+
1954
+ /**
1955
+ * kastlan 8: a caller's placeholder on a LABELLED field, shown once the label has
1956
+ * floated out of its way — on focus — and transparent until then, while the label sits
1957
+ * where the placeholder would be. Without it the field forced `placeholder=" "` for the
1958
+ * float trick and dropped the caller's text without a word (kastlan's "note" field lost
1959
+ * its hint). `:placeholder-shown` still drives the float: it matches an empty field
1960
+ * whatever the placeholder's text is.
1961
+ */
1962
+ const FLOATING_PLACEHOLDER_ON_FOCUS = "focus:placeholder:text-[var(--text-placeholder)]";
1963
+
1964
+ /** A placeholder with something to show. `""` and blanks are the float trick's own
1965
+ * `" "`, never the caller's hint. */
1966
+ const hasPlaceholderText = (placeholder: string | undefined): placeholder is string =>
1967
+ placeholder !== undefined && placeholder.trim() !== "";
1968
+
1843
1969
  // Native date/time inputs only reveal the calendar via the tiny trailing icon;
1844
1970
  // open the picker on a click anywhere in the field instead (feedback #224).
1845
1971
  const PICKER_TYPES = new Set(["date", "datetime-local", "month", "time", "week"]);
@@ -1874,12 +2000,42 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
1874
2000
  * `invalid`, so the field paints as well as announces. `invalid` alone still
1875
2001
  * covers the case where the message lives elsewhere. See {@link useFieldError}. */
1876
2002
  error?: ReactNode;
2003
+ /**
2004
+ * Standing advice for the field (keksdose K4: Input had `error` but no `hint`, so the
2005
+ * app built the caption by hand under each field, and half of those never reached a
2006
+ * screen reader). The {@link Select} rule, so every field reads alike:
2007
+ * - plain TEXT (a string or a number) is a caption UNDER the field, attached with
2008
+ * `aria-describedby` — after the caller's own ids, before the error's;
2009
+ * - anything else (a {@link FieldHint} "?") rides the label line beside the
2010
+ * animated label, as NumberInput's does — or, on a field with no label, sits at the
2011
+ * field's end edge outside the box, and `className` then styles that row.
2012
+ */
2013
+ hint?: ReactNode;
2014
+ /**
2015
+ * A live "12/80" under the field, with `maxLength` (keksdose K11 — the broadcast,
2016
+ * support and handoff-note fields counted by hand). Read with the field as "12 of 80
2017
+ * characters", and announced politely only on entering the last stretch and on
2018
+ * reaching the limit — never on every keystroke. See {@link CharacterCount}. Ignored
2019
+ * without a `maxLength`: a count with no limit is not what this answers. Counts
2020
+ * UTF-16 code units, as `maxLength` itself does. Controlled, it counts `value`;
2021
+ * uncontrolled, `defaultValue` and then every change event — a value written from
2022
+ * outside without one (a form library's `reset`) shows from the next keystroke.
2023
+ */
2024
+ showCount?: boolean;
2025
+ /** The counter's words. See {@link CharacterCountLabels}. */
2026
+ countLabels?: Partial<CharacterCountLabels>;
1877
2027
  /** Names for the password reveal toggle, English by default — it is the one
1878
2028
  * string this component renders on its own behalf, and a German form was
1879
2029
  * reading it out in English. See {@link PasswordRevealLabels}. */
1880
2030
  passwordLabels?: Partial<PasswordRevealLabels>;
1881
2031
  }
1882
2032
 
2033
+ /** The counter's words: the `characterCount` namespace — prop > `<UiKitProvider
2034
+ * labels>` > English. One place, so the namespace is named once. */
2035
+ function useCharacterCountLabels(prop: Partial<CharacterCountLabels> | undefined): CharacterCountLabels {
2036
+ return useKitLabels("characterCount", DEFAULT_CHARACTER_COUNT_LABELS, prop);
2037
+ }
2038
+
1883
2039
  export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, ref) {
1884
2040
  const {
1885
2041
  className,
@@ -1891,19 +2047,47 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(pro
1891
2047
  variant = "field",
1892
2048
  invalid,
1893
2049
  error,
2050
+ hint,
2051
+ showCount,
2052
+ countLabels,
1894
2053
  passwordLabels,
1895
2054
  ...rest
1896
2055
  } = props;
1897
- // Passed at all — even as `undefined` — the field keeps its box; see FieldGroup.
1898
- const reserve = "error" in props;
2056
+ // Passed at all — even as `undefined` — the field keeps its box; see FieldGroup. The
2057
+ // same for `hint` and `showCount` (keksdose, 0.22): a caption coming and going under
2058
+ // the control must not rebuild it, or focus and the caret are lost mid-edit.
2059
+ const reserve = "error" in props || "hint" in props || "showCount" in props;
1899
2060
  const generated = useId();
1900
2061
  const fieldId = id ?? generated;
2062
+ const countId = useId();
2063
+ const hintParts = useFieldHint(hint, rest["aria-describedby"]);
2064
+ const maxLength = rest.maxLength;
2065
+ const counting = Boolean(showCount) && typeof maxLength === "number" && maxLength > 0;
2066
+ const length = useTypedLength(rest.value, rest.defaultValue);
2067
+ const countText = useCharacterCountLabels(countLabels);
1901
2068
  const { isInvalid, describedBy, errorEl } = useFieldError(
1902
2069
  error,
1903
2070
  invalid,
1904
- rest["aria-describedby"],
2071
+ // Caller's, caption, count — then the error, appended by the hook.
2072
+ mergeDescribedBy(hintParts.describedBy, counting && countId),
1905
2073
  rest["aria-invalid"],
1906
2074
  );
2075
+ const counter = counting ? (
2076
+ <CharacterCount
2077
+ id={countId}
2078
+ used={length.used}
2079
+ max={maxLength}
2080
+ labels={countText}
2081
+ caption={<FieldCaption parts={hintParts} className="mt-0" />}
2082
+ />
2083
+ ) : null;
2084
+ const below = fieldBelow(hintParts, counter, errorEl);
2085
+ const onChange = counting
2086
+ ? (e: ChangeEvent<HTMLInputElement>) => {
2087
+ length.track(e.target.value);
2088
+ rest.onChange?.(e);
2089
+ }
2090
+ : rest.onChange;
1907
2091
  const asDisplay = useMediaQuery(PHONE_QUERY, false) && variant === "display";
1908
2092
  // Password fields get a reveal toggle so users can check what they typed.
1909
2093
  const isPassword = type === "password";
@@ -1943,66 +2127,92 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(pro
1943
2127
  </button>
1944
2128
  ) : null;
1945
2129
  if (label === undefined) {
2130
+ // A "?" with no label line to ride goes at the end edge, outside the box, and
2131
+ // `className` moves to that row (see `hint`); without one nothing moves.
2132
+ const endHint = hintParts.labelHint;
2133
+ const ownClass = endHint === undefined ? className : undefined;
1946
2134
  if (!isPassword) {
1947
2135
  return (
1948
- <FieldGroup errorEl={errorEl} reserve={reserve}>
1949
- <input
1950
- ref={ref}
1951
- id={id}
1952
- type={type}
1953
- placeholder={placeholder}
1954
- {...rest}
1955
- // AFTER the spread, so the prop wins — but OR-ed with whatever the spread
1956
- // carried, or setting `invalid` would have quietly deleted a caller's own
1957
- // `aria-invalid`. The prop is the one that also paints; a bare attribute
1958
- // still announces, which is all it ever did.
1959
- aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
1960
- // Likewise merged rather than replaced — see {@link useFieldError}.
1961
- aria-describedby={describedBy}
1962
- onClick={handleClick}
1963
- className={cn(FIELD_BASE, className, inputClassName, isInvalid && FIELD_INVALID)}
1964
- />
2136
+ <FieldGroup errorEl={below} reserve={reserve}>
2137
+ <EndHintRow hint={endHint} className={className}>
2138
+ <input
2139
+ ref={ref}
2140
+ id={id}
2141
+ type={type}
2142
+ placeholder={placeholder}
2143
+ {...rest}
2144
+ // AFTER the spread, so the prop wins — but OR-ed with whatever the spread
2145
+ // carried, or setting `invalid` would have quietly deleted a caller's own
2146
+ // `aria-invalid`. The prop is the one that also paints; a bare attribute
2147
+ // still announces, which is all it ever did.
2148
+ aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2149
+ // Likewise merged rather than replaced — see {@link useFieldError}.
2150
+ aria-describedby={describedBy}
2151
+ onClick={handleClick}
2152
+ onChange={onChange}
2153
+ className={cn(FIELD_BASE, ownClass, inputClassName, isInvalid && FIELD_INVALID)}
2154
+ />
2155
+ </EndHintRow>
1965
2156
  </FieldGroup>
1966
2157
  );
1967
2158
  }
1968
2159
  return (
1969
- <FieldGroup errorEl={errorEl} reserve={reserve}>
1970
- <div className={cn("relative", className)}>
1971
- <input
1972
- ref={ref}
1973
- id={id}
1974
- type={effectiveType}
1975
- placeholder={placeholder}
1976
- {...rest}
1977
- aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
1978
- aria-describedby={describedBy}
1979
- className={cn(FIELD_BASE, "pe-9", inputClassName, isInvalid && FIELD_INVALID)}
1980
- />
1981
- {revealToggle}
1982
- </div>
2160
+ <FieldGroup errorEl={below} reserve={reserve}>
2161
+ <EndHintRow hint={endHint} className={className}>
2162
+ <div className={cn("relative", ownClass)}>
2163
+ <input
2164
+ ref={ref}
2165
+ id={id}
2166
+ type={effectiveType}
2167
+ placeholder={placeholder}
2168
+ {...rest}
2169
+ aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2170
+ aria-describedby={describedBy}
2171
+ onChange={onChange}
2172
+ className={cn(FIELD_BASE, "pe-9", inputClassName, isInvalid && FIELD_INVALID)}
2173
+ />
2174
+ {revealToggle}
2175
+ </div>
2176
+ </EndHintRow>
1983
2177
  </FieldGroup>
1984
2178
  );
1985
2179
  }
2180
+ const ownPlaceholder = hasPlaceholderText(placeholder);
1986
2181
  return (
1987
- <FieldGroup errorEl={errorEl} reserve={reserve}>
1988
- <FloatingField className={className} htmlFor={fieldId} label={label} srOnlyLabel={asDisplay}>
2182
+ <FieldGroup errorEl={below} reserve={reserve}>
2183
+ <FloatingField
2184
+ className={className}
2185
+ htmlFor={fieldId}
2186
+ label={label}
2187
+ srOnlyLabel={asDisplay}
2188
+ hint={hintParts.labelHint}
2189
+ >
1989
2190
  <input
1990
2191
  ref={ref}
1991
2192
  id={fieldId}
1992
2193
  type={effectiveType}
1993
- // A labelled field's placeholder is normally a single space, feeding the
1994
- // floating label's peer-placeholder-shown trick. With the label sr-only
1995
- // there is no float left to drive, and an empty borderless line would say
1996
- // nothing at all — so the label text becomes the placeholder.
1997
- placeholder={asDisplay ? (placeholder ?? (typeof label === "string" ? label : " ")) : " "}
2194
+ // A labelled field with no placeholder of its own gets a single space, which
2195
+ // feeds the floating label's peer-placeholder-shown trick. A caller's own is
2196
+ // kept and shown on focus, once the label has floated out of its way (kastlan
2197
+ // 8) — see FLOATING_PLACEHOLDER_ON_FOCUS. With the label sr-only there is no
2198
+ // float left to drive, and an empty borderless line would say nothing at all
2199
+ // — so there the label text is the placeholder when the caller gave none.
2200
+ placeholder={
2201
+ asDisplay
2202
+ ? (placeholder ?? (typeof label === "string" ? label : " "))
2203
+ : ownPlaceholder
2204
+ ? placeholder
2205
+ : " "
2206
+ }
1998
2207
  {...rest}
1999
2208
  aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2000
2209
  aria-describedby={describedBy}
2001
2210
  onClick={handleClick}
2211
+ onChange={onChange}
2002
2212
  className={cn(
2003
2213
  asDisplay
2004
2214
  ? cn(FIELD_DISPLAY, "text-xl font-semibold leading-snug")
2005
- : FLOATING_INPUT_CLASS,
2215
+ : cn(FLOATING_INPUT_CLASS, ownPlaceholder && FLOATING_PLACEHOLDER_ON_FOCUS),
2006
2216
  isPassword && "pe-9",
2007
2217
  inputClassName,
2008
2218
  isInvalid && FIELD_INVALID,
@@ -2045,6 +2255,29 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
2045
2255
  * the chevron is positioned against — so it could set a width and nothing
2046
2256
  * else; this is the way to the element. See {@link Input}'s `inputClassName`. */
2047
2257
  selectClassName?: string;
2258
+ /**
2259
+ * Why the choice cannot be changed — {@link Button}'s `disabledReason`, for a select
2260
+ * that SAVES on change (a role picker in a members table, a status in a row). keksdose
2261
+ * K3: those sat inside a hand-rolled `SaveGuard` that forced a native `disabled`, which
2262
+ * took the field out of the tab order so the reason never reached a keyboard.
2263
+ *
2264
+ * With a reason the select is `aria-disabled` instead — still focusable, still showing
2265
+ * its value — its list does not open (pointer and keys are swallowed, Tab and Escape
2266
+ * excepted), `onChange` is never called, and the reason is in the kit {@link Tooltip}
2267
+ * and on `aria-describedby`. It wears the settled look of a disabled field and drops
2268
+ * its chevron, as a disabled Select does (dev#474). It wins over `disabled`.
2269
+ *
2270
+ * Use it CONTROLLED (`value` + `onChange`): React puts a controlled value back after
2271
+ * the swallowed change. A touch platform's own picker may still open; nothing it picks
2272
+ * is kept or reported.
2273
+ */
2274
+ disabledReason?: ReactNode;
2275
+ /**
2276
+ * This select COMMITS — choosing saves. Under a locked {@link WriteLockProvider} it is
2277
+ * locked the `disabledReason` way with the lock's reason (which wins over its own).
2278
+ * No provider, or an unlocked one: no effect. Button's `commit`, for keksdose K3.
2279
+ */
2280
+ commit?: boolean;
2048
2281
  }
2049
2282
 
2050
2283
  // The compact select: the field's colours, a toolbar button's height. `py-0` and a
@@ -2052,12 +2285,37 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
2052
2285
  // height the caller's type brings with it.
2053
2286
  const SELECT_SM = "h-7 py-0 ps-2 pe-7 text-xs";
2054
2287
 
2288
+ /** Keys a locked select still answers: leaving it, and dismissing its tooltip. Every
2289
+ * other key would open the list or step the value. */
2290
+ const LOCKED_SELECT_KEYS = new Set(["Tab", "Escape", "Shift"]);
2291
+
2292
+ /** FIELD_BASE's `disabled:` look, for a select that is locked without being `disabled`
2293
+ * (the `:disabled` variants cannot match it). */
2294
+ const FIELD_LOCKED = "cursor-not-allowed bg-[var(--bg-surface-2)] text-[var(--text-muted)]";
2295
+
2055
2296
  export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(props, ref) {
2056
- const { className, label, id, children, invalid, error, hint, size, selectClassName, ...rest } =
2057
- props;
2297
+ const {
2298
+ className,
2299
+ label,
2300
+ id,
2301
+ children,
2302
+ invalid,
2303
+ error,
2304
+ hint,
2305
+ size,
2306
+ selectClassName,
2307
+ disabledReason,
2308
+ commit,
2309
+ onMouseDown,
2310
+ onKeyDown,
2311
+ onChange,
2312
+ ...rest
2313
+ } = props;
2058
2314
  // See Input's `reserve`.
2059
- const reserve = "error" in props;
2315
+ const reserve = "error" in props || "hint" in props || "showCount" in props;
2060
2316
  const generated = useId();
2317
+ const lock = useLockReason(commit, disabledReason);
2318
+ const locked = lock.locked;
2061
2319
  // Only a number reaches the DOM; the two words are this component's own.
2062
2320
  const nativeSize = typeof size === "number" ? size : undefined;
2063
2321
  // A numeric size above 1 (or `multiple`) makes the browser draw a LIST BOX — all
@@ -2076,12 +2334,9 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
2076
2334
  error,
2077
2335
  invalid,
2078
2336
  // The caption goes in BEFORE the error — the standing advice first, the news
2079
- // second, the order useFieldError keeps for a caller's own description.
2080
- textHint
2081
- ? rest["aria-describedby"]
2082
- ? `${rest["aria-describedby"]} ${hintId}`
2083
- : hintId
2084
- : rest["aria-describedby"],
2337
+ // second, the order useFieldError keeps for a caller's own description. A lock's
2338
+ // reason sits between the two.
2339
+ mergeDescribedBy(rest["aria-describedby"], textHint && hintId, locked && lock.reasonId),
2085
2340
  rest["aria-invalid"],
2086
2341
  );
2087
2342
  const below =
@@ -2099,9 +2354,10 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
2099
2354
  // in from the end border and matches both themes — feedback #223. A DISABLED
2100
2355
  // select has no menu to drop, so it drops the chevron too: the arrow is the one
2101
2356
  // thing on the control that promises a choice (Keksdose dev#474, where the
2102
- // account type became read-only and still looked exactly like a picker).
2357
+ // account type became read-only and still looked exactly like a picker). A locked
2358
+ // one has no menu either.
2103
2359
  const chevron =
2104
- rest.disabled || listBox ? null : (
2360
+ rest.disabled || listBox || locked ? null : (
2105
2361
  <FieldChevron className={small ? "end-1.5 size-3.5" : undefined} />
2106
2362
  );
2107
2363
  // `pe-9` is the room the chevron takes; a list box has none to make room for.
@@ -2113,6 +2369,32 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
2113
2369
  // browser sizes the box by), and the box keeps no bottom padding: exactly `size`
2114
2370
  // whole rows, at any width.
2115
2371
  const dress = listBox ? "overflow-y-auto [&_option]:py-1" : "appearance-none pe-9";
2372
+ // Locked: focusable, but nothing opens the list or moves the value, and nothing is
2373
+ // reported. The no-op `onChange` keeps a controlled select's value where React put
2374
+ // it (and keeps React from warning about a `value` with no handler).
2375
+ const interaction = locked
2376
+ ? {
2377
+ disabled: undefined,
2378
+ "aria-disabled": true as const,
2379
+ onMouseDown: (e: MouseEvent<HTMLSelectElement>) => e.preventDefault(),
2380
+ onKeyDown: (e: KeyboardEvent<HTMLSelectElement>) => {
2381
+ if (!LOCKED_SELECT_KEYS.has(e.key) && !e.ctrlKey && !e.metaKey) e.preventDefault();
2382
+ },
2383
+ onChange: () => {},
2384
+ }
2385
+ : { onMouseDown, onKeyDown, onChange };
2386
+ // While locked the field sits in the reason's Tooltip, which becomes the outermost
2387
+ // box — so `className` (a width, a grid cell) moves onto it, and the form keeps its
2388
+ // layout. `block` so a full-width field stays full width inside it.
2389
+ const boxClass = locked ? undefined : className;
2390
+ const withReason = (box: ReactNode) =>
2391
+ locked ? (
2392
+ <LockedReason lock={lock} className={cn("block", className)}>
2393
+ {box}
2394
+ </LockedReason>
2395
+ ) : (
2396
+ box
2397
+ );
2116
2398
  if (label === undefined) {
2117
2399
  const select = (
2118
2400
  <select
@@ -2126,6 +2408,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
2126
2408
  id={id}
2127
2409
  size={nativeSize}
2128
2410
  {...rest}
2411
+ {...interaction}
2129
2412
  // OR-ed with the spread for the reason spelled out on Input's copy: this
2130
2413
  // branch wrote `invalid || undefined`, so passing `aria-invalid` by hand
2131
2414
  // to a Select — which is what a caller does when the validity is
@@ -2138,6 +2421,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
2138
2421
  dress,
2139
2422
  listBox && "py-0",
2140
2423
  small && SELECT_SM,
2424
+ locked && FIELD_LOCKED,
2141
2425
  selectClassName,
2142
2426
  isInvalid && FIELD_INVALID,
2143
2427
  )}
@@ -2153,51 +2437,65 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
2153
2437
  if (hasContent(hint) && !textHint) {
2154
2438
  return (
2155
2439
  <FieldGroup errorEl={below} reserve={reserve}>
2156
- <div className={cn("flex items-center gap-1.5", className)}>
2157
- <div className="relative min-w-0 flex-1">
2158
- {select}
2159
- {chevron}
2160
- </div>
2161
- <span className="flex shrink-0 items-center">{hint}</span>
2162
- </div>
2440
+ {withReason(
2441
+ <div className={cn("flex items-center gap-1.5", boxClass)}>
2442
+ <div className="relative min-w-0 flex-1">
2443
+ {select}
2444
+ {chevron}
2445
+ </div>
2446
+ <span className="flex shrink-0 items-center">{hint}</span>
2447
+ </div>,
2448
+ )}
2163
2449
  </FieldGroup>
2164
2450
  );
2165
2451
  }
2166
2452
  return (
2167
2453
  <FieldGroup errorEl={below} reserve={reserve}>
2168
- <div className={cn("relative", className)}>
2169
- {select}
2170
- {chevron}
2171
- </div>
2454
+ {withReason(
2455
+ <div className={cn("relative", boxClass)}>
2456
+ {select}
2457
+ {chevron}
2458
+ </div>,
2459
+ )}
2172
2460
  </FieldGroup>
2173
2461
  );
2174
2462
  }
2175
2463
  return (
2176
2464
  <FieldGroup errorEl={below} reserve={reserve}>
2177
- <FloatingField className={className} htmlFor={fieldId} label={label} staticLabel hint={textHint ? undefined : hint}>
2178
- <select
2179
- ref={ref}
2180
- id={fieldId}
2181
- size={nativeSize}
2182
- {...rest}
2183
- aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2184
- aria-describedby={describedBy}
2185
- className={cn(
2186
- FIELD_BASE,
2187
- // The floated label takes the top strip of a list box too, so its first
2188
- // row starts under the label rather than behind it. No bottom padding — see
2189
- // `dress`.
2190
- listBox ? "pt-5 pb-0" : FIELD_FLOATING_PAD,
2191
- "peer",
2192
- dress,
2193
- selectClassName,
2194
- isInvalid && FIELD_INVALID,
2195
- )}
2465
+ {withReason(
2466
+ <FloatingField
2467
+ className={boxClass}
2468
+ htmlFor={fieldId}
2469
+ label={label}
2470
+ staticLabel
2471
+ hint={textHint ? undefined : hint}
2196
2472
  >
2197
- {children}
2198
- </select>
2199
- {chevron}
2200
- </FloatingField>
2473
+ <select
2474
+ ref={ref}
2475
+ id={fieldId}
2476
+ size={nativeSize}
2477
+ {...rest}
2478
+ {...interaction}
2479
+ aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2480
+ aria-describedby={describedBy}
2481
+ className={cn(
2482
+ FIELD_BASE,
2483
+ // The floated label takes the top strip of a list box too, so its first
2484
+ // row starts under the label rather than behind it. No bottom padding — see
2485
+ // `dress`.
2486
+ listBox ? "pt-5 pb-0" : FIELD_FLOATING_PAD,
2487
+ "peer",
2488
+ dress,
2489
+ locked && FIELD_LOCKED,
2490
+ selectClassName,
2491
+ isInvalid && FIELD_INVALID,
2492
+ )}
2493
+ >
2494
+ {children}
2495
+ </select>
2496
+ {chevron}
2497
+ </FloatingField>,
2498
+ )}
2201
2499
  </FieldGroup>
2202
2500
  );
2203
2501
  });
@@ -2209,46 +2507,89 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
2209
2507
  invalid?: boolean;
2210
2508
  /** See {@link Input}'s `error`. */
2211
2509
  error?: ReactNode;
2510
+ /** See {@link Input}'s `hint` (keksdose K4): text is a caption under the field, a
2511
+ * {@link FieldHint} rides the label line (or, unlabelled, the end edge). */
2512
+ hint?: ReactNode;
2513
+ /** See {@link Input}'s `showCount` (keksdose K11): a live "12/80" under the field,
2514
+ * with `maxLength`, announced only near and at the limit. */
2515
+ showCount?: boolean;
2516
+ /** The counter's words. See {@link CharacterCountLabels}. */
2517
+ countLabels?: Partial<CharacterCountLabels>;
2212
2518
  }
2213
2519
 
2214
2520
  export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(props, ref) {
2215
- const { className, label, id, placeholder, invalid, error, ...rest } = props;
2521
+ const { className, label, id, placeholder, invalid, error, hint, showCount, countLabels, ...rest } = props;
2216
2522
  // See Input's `reserve`.
2217
- const reserve = "error" in props;
2523
+ const reserve = "error" in props || "hint" in props || "showCount" in props;
2218
2524
  const generated = useId();
2219
2525
  const fieldId = id ?? generated;
2526
+ const countId = useId();
2527
+ const hintParts = useFieldHint(hint, rest["aria-describedby"]);
2528
+ const maxLength = rest.maxLength;
2529
+ const counting = Boolean(showCount) && typeof maxLength === "number" && maxLength > 0;
2530
+ const length = useTypedLength(rest.value, rest.defaultValue);
2531
+ const countText = useCharacterCountLabels(countLabels);
2220
2532
  const { isInvalid, describedBy, errorEl } = useFieldError(
2221
2533
  error,
2222
2534
  invalid,
2223
- rest["aria-describedby"],
2535
+ mergeDescribedBy(hintParts.describedBy, counting && countId),
2224
2536
  rest["aria-invalid"],
2225
2537
  );
2538
+ const counter = counting ? (
2539
+ <CharacterCount
2540
+ id={countId}
2541
+ used={length.used}
2542
+ max={maxLength}
2543
+ labels={countText}
2544
+ caption={<FieldCaption parts={hintParts} className="mt-0" />}
2545
+ />
2546
+ ) : null;
2547
+ const below = fieldBelow(hintParts, counter, errorEl);
2548
+ const onChange = counting
2549
+ ? (e: ChangeEvent<HTMLTextAreaElement>) => {
2550
+ length.track(e.target.value);
2551
+ rest.onChange?.(e);
2552
+ }
2553
+ : rest.onChange;
2226
2554
  if (label === undefined) {
2555
+ const endHint = hintParts.labelHint;
2227
2556
  return (
2228
- <FieldGroup errorEl={errorEl} reserve={reserve}>
2229
- <textarea
2230
- ref={ref}
2231
- id={id}
2232
- placeholder={placeholder}
2233
- {...rest}
2234
- aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2235
- aria-describedby={describedBy}
2236
- className={cn(FIELD_BASE, className, isInvalid && FIELD_INVALID)}
2237
- />
2557
+ <FieldGroup errorEl={below} reserve={reserve}>
2558
+ <EndHintRow hint={endHint} className={className}>
2559
+ <textarea
2560
+ ref={ref}
2561
+ id={id}
2562
+ placeholder={placeholder}
2563
+ {...rest}
2564
+ aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2565
+ aria-describedby={describedBy}
2566
+ onChange={onChange}
2567
+ className={cn(FIELD_BASE, endHint === undefined && className, isInvalid && FIELD_INVALID)}
2568
+ />
2569
+ </EndHintRow>
2238
2570
  </FieldGroup>
2239
2571
  );
2240
2572
  }
2573
+ const ownPlaceholder = hasPlaceholderText(placeholder);
2241
2574
  return (
2242
- <FieldGroup errorEl={errorEl} reserve={reserve}>
2243
- <FloatingField className={className} htmlFor={fieldId} label={label}>
2575
+ <FieldGroup errorEl={below} reserve={reserve}>
2576
+ <FloatingField className={className} htmlFor={fieldId} label={label} hint={hintParts.labelHint}>
2244
2577
  <textarea
2245
2578
  ref={ref}
2246
2579
  id={fieldId}
2247
- placeholder=" "
2580
+ // The float trick's single space, unless the caller has a placeholder of its
2581
+ // own — then that, shown on focus once the label is out of its way (kastlan
2582
+ // 8, see FLOATING_PLACEHOLDER_ON_FOCUS).
2583
+ placeholder={ownPlaceholder ? placeholder : " "}
2248
2584
  {...rest}
2249
2585
  aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2250
2586
  aria-describedby={describedBy}
2251
- className={cn(FLOATING_INPUT_CLASS, isInvalid && FIELD_INVALID)}
2587
+ onChange={onChange}
2588
+ className={cn(
2589
+ FLOATING_INPUT_CLASS,
2590
+ ownPlaceholder && FLOATING_PLACEHOLDER_ON_FOCUS,
2591
+ isInvalid && FIELD_INVALID,
2592
+ )}
2252
2593
  />
2253
2594
  </FloatingField>
2254
2595
  </FieldGroup>