@eifi1/ui-kit 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (278) hide show
  1. package/dist/chart.d.ts +10 -8
  2. package/dist/components/account-chips.d.ts +10 -3
  3. package/dist/components/account-chips.js +5 -2
  4. package/dist/components/account-chips.js.map +1 -1
  5. package/dist/components/account-settings.d.ts +33 -2
  6. package/dist/components/account-settings.js +43 -21
  7. package/dist/components/account-settings.js.map +1 -1
  8. package/dist/components/amount-input.d.ts +27 -8
  9. package/dist/components/amount-input.js +15 -28
  10. package/dist/components/amount-input.js.map +1 -1
  11. package/dist/components/autocomplete.d.ts +8 -0
  12. package/dist/components/autocomplete.js +9 -4
  13. package/dist/components/autocomplete.js.map +1 -1
  14. package/dist/components/button-group.d.ts +10 -8
  15. package/dist/components/calculator.d.ts +10 -8
  16. package/dist/components/checkbox-group.d.ts +104 -0
  17. package/dist/components/checkbox-group.js +111 -0
  18. package/dist/components/checkbox-group.js.map +1 -0
  19. package/dist/components/checkbox.d.ts +19 -0
  20. package/dist/components/checkbox.js +30 -7
  21. package/dist/components/checkbox.js.map +1 -1
  22. package/dist/components/chip.d.ts +18 -0
  23. package/dist/components/chip.js +47 -10
  24. package/dist/components/chip.js.map +1 -1
  25. package/dist/components/combobox.d.ts +13 -2
  26. package/dist/components/combobox.js +17 -8
  27. package/dist/components/combobox.js.map +1 -1
  28. package/dist/components/confirm-dialog.d.ts +66 -161
  29. package/dist/components/copy-button.d.ts +10 -8
  30. package/dist/components/country-select.d.ts +124 -0
  31. package/dist/components/country-select.js +237 -0
  32. package/dist/components/country-select.js.map +1 -0
  33. package/dist/components/currency-select.d.ts +17 -1
  34. package/dist/components/currency-select.js +56 -23
  35. package/dist/components/currency-select.js.map +1 -1
  36. package/dist/components/danger-confirm.d.ts +66 -166
  37. package/dist/components/danger-confirm.js +133 -49
  38. package/dist/components/danger-confirm.js.map +1 -1
  39. package/dist/components/data-table-cells.d.ts +1 -2
  40. package/dist/components/data-table-filter-popover.d.ts +1 -2
  41. package/dist/components/data-table-filters.d.ts +1 -2
  42. package/dist/components/data-table-labels.d.ts +4 -97
  43. package/dist/components/data-table-labels.js +9 -1
  44. package/dist/components/data-table-labels.js.map +1 -1
  45. package/dist/components/data-table-pagination.d.ts +3 -1
  46. package/dist/components/data-table.d.ts +1 -2
  47. package/dist/components/data-table.js +98 -3
  48. package/dist/components/data-table.js.map +1 -1
  49. package/dist/components/date-picker.d.ts +25 -5
  50. package/dist/components/date-picker.js +110 -59
  51. package/dist/components/date-picker.js.map +1 -1
  52. package/dist/components/entity-combobox.d.ts +8 -1
  53. package/dist/components/entity-combobox.js +9 -4
  54. package/dist/components/entity-combobox.js.map +1 -1
  55. package/dist/components/facing-pair.d.ts +10 -8
  56. package/dist/components/field-anatomy.d.ts +95 -0
  57. package/dist/components/field-anatomy.js +84 -0
  58. package/dist/components/field-anatomy.js.map +1 -0
  59. package/dist/components/field-parts.d.ts +114 -0
  60. package/dist/components/field-parts.js +72 -0
  61. package/dist/components/field-parts.js.map +1 -0
  62. package/dist/components/file-button.d.ts +10 -8
  63. package/dist/components/file-button.js +13 -3
  64. package/dist/components/file-button.js.map +1 -1
  65. package/dist/components/file-dropzone.d.ts +32 -9
  66. package/dist/components/file-dropzone.js +57 -34
  67. package/dist/components/file-dropzone.js.map +1 -1
  68. package/dist/components/form-actions.d.ts +10 -8
  69. package/dist/components/form-actions.js +61 -3
  70. package/dist/components/form-actions.js.map +1 -1
  71. package/dist/components/iban-input.d.ts +66 -0
  72. package/dist/components/iban-input.js +125 -0
  73. package/dist/components/iban-input.js.map +1 -0
  74. package/dist/components/icon-picker.d.ts +12 -2
  75. package/dist/components/icon-picker.js +9 -2
  76. package/dist/components/icon-picker.js.map +1 -1
  77. package/dist/components/inline-edit-field.d.ts +167 -0
  78. package/dist/components/inline-edit-field.js +293 -0
  79. package/dist/components/inline-edit-field.js.map +1 -0
  80. package/dist/components/language-select.d.ts +127 -0
  81. package/dist/components/language-select.js +18 -0
  82. package/dist/components/language-select.js.map +1 -0
  83. package/dist/components/line-items.d.ts +25 -3
  84. package/dist/components/line-items.js +55 -46
  85. package/dist/components/line-items.js.map +1 -1
  86. package/dist/components/list.d.ts +25 -0
  87. package/dist/components/list.js +16 -5
  88. package/dist/components/list.js.map +1 -1
  89. package/dist/components/money-field.d.ts +20 -8
  90. package/dist/components/money-field.js.map +1 -1
  91. package/dist/components/month-picker.d.ts +52 -4
  92. package/dist/components/month-picker.js +290 -86
  93. package/dist/components/month-picker.js.map +1 -1
  94. package/dist/components/multi-select.d.ts +8 -1
  95. package/dist/components/multi-select.js +9 -4
  96. package/dist/components/multi-select.js.map +1 -1
  97. package/dist/components/number-field.d.ts +25 -13
  98. package/dist/components/number-field.js +6 -20
  99. package/dist/components/number-field.js.map +1 -1
  100. package/dist/components/number-input.d.ts +70 -13
  101. package/dist/components/number-input.js +90 -38
  102. package/dist/components/number-input.js.map +1 -1
  103. package/dist/components/numpad-sheet.d.ts +10 -8
  104. package/dist/components/one-time-code-input.d.ts +100 -0
  105. package/dist/components/one-time-code-input.js +137 -0
  106. package/dist/components/one-time-code-input.js.map +1 -0
  107. package/dist/components/phone-input.d.ts +66 -0
  108. package/dist/components/phone-input.js +171 -0
  109. package/dist/components/phone-input.js.map +1 -0
  110. package/dist/components/series-chart.d.ts +10 -8
  111. package/dist/components/settings-fields.d.ts +10 -8
  112. package/dist/components/share-card.d.ts +8 -6
  113. package/dist/components/sign-chip.d.ts +66 -0
  114. package/dist/components/sign-chip.js +39 -0
  115. package/dist/components/sign-chip.js.map +1 -0
  116. package/dist/components/swatch-picker.d.ts +27 -2
  117. package/dist/components/swatch-picker.js +73 -31
  118. package/dist/components/swatch-picker.js.map +1 -1
  119. package/dist/components/switch.d.ts +19 -0
  120. package/dist/components/switch.js +24 -6
  121. package/dist/components/switch.js.map +1 -1
  122. package/dist/components/text-link.d.ts +10 -8
  123. package/dist/components/tile-radio.d.ts +68 -13
  124. package/dist/components/tile-radio.js +3 -3
  125. package/dist/components/tile-radio.js.map +1 -1
  126. package/dist/components/time-input.d.ts +15 -9
  127. package/dist/components/time-input.js.map +1 -1
  128. package/dist/components/toggle-group.d.ts +47 -1
  129. package/dist/components/toggle-group.js +110 -69
  130. package/dist/components/toggle-group.js.map +1 -1
  131. package/dist/components/ui.d.ts +10 -8
  132. package/dist/components/ui.js +280 -78
  133. package/dist/components/ui.js.map +1 -1
  134. package/dist/components/use-table-state.d.ts +1 -2
  135. package/dist/{data-table-3mi2R3so.d.ts → data-table-labels-B7OdnM0S.d.ts} +140 -3
  136. package/dist/data-table.d.ts +1 -2
  137. package/dist/data-table.js +2 -1
  138. package/dist/data-table.js.map +1 -1
  139. package/dist/feedback/feedback-attachment.d.ts +1 -1
  140. package/dist/feedback/feedback-attachment.js +210 -3
  141. package/dist/feedback/feedback-attachment.js.map +1 -1
  142. package/dist/feedback/feedback-dialog.d.ts +1 -1
  143. package/dist/feedback/feedback-dialog.js.map +1 -1
  144. package/dist/feedback/feedback-inbox.d.ts +1 -1
  145. package/dist/feedback/feedback-thread.d.ts +37 -3
  146. package/dist/feedback/feedback-thread.js +38 -9
  147. package/dist/feedback/feedback-thread.js.map +1 -1
  148. package/dist/{feedback-attachment-WFiXJ8We.d.ts → feedback-attachment-NhUm7Zga.d.ts} +70 -5
  149. package/dist/feedback.d.ts +2 -2
  150. package/dist/hooks/use-file-drop.d.ts +10 -8
  151. package/dist/i18n/defaults.d.ts +10 -8
  152. package/dist/i18n/defaults.js +17 -2
  153. package/dist/i18n/defaults.js.map +1 -1
  154. package/dist/i18n/german.d.ts +10 -8
  155. package/dist/i18n/german.js +50 -5
  156. package/dist/i18n/german.js.map +1 -1
  157. package/dist/i18n/kit-labels.d.ts +10 -8
  158. package/dist/i18n/kit-labels.js +8 -2
  159. package/dist/i18n/kit-labels.js.map +1 -1
  160. package/dist/i18n/languages.d.ts +10 -8
  161. package/dist/i18n/locales/de-CH.d.ts +10 -8
  162. package/dist/i18n/locales/en.d.ts +10 -8
  163. package/dist/i18n/locales/en.js +9 -0
  164. package/dist/i18n/locales/en.js.map +1 -1
  165. package/dist/i18n/locales/es.d.ts +10 -8
  166. package/dist/i18n/locales/es.js +48 -5
  167. package/dist/i18n/locales/es.js.map +1 -1
  168. package/dist/i18n/locales/fr.d.ts +10 -8
  169. package/dist/i18n/locales/fr.js +48 -5
  170. package/dist/i18n/locales/fr.js.map +1 -1
  171. package/dist/i18n/locales/hu.d.ts +10 -8
  172. package/dist/i18n/locales/hu.js +49 -5
  173. package/dist/i18n/locales/hu.js.map +1 -1
  174. package/dist/i18n/locales/it.d.ts +10 -8
  175. package/dist/i18n/locales/it.js +49 -5
  176. package/dist/i18n/locales/it.js.map +1 -1
  177. package/dist/i18n/locales/zh.d.ts +10 -8
  178. package/dist/i18n/locales/zh.js +48 -5
  179. package/dist/i18n/locales/zh.js.map +1 -1
  180. package/dist/i18n/review.d.ts +10 -8
  181. package/dist/i18n/review.js +8 -1
  182. package/dist/i18n/review.js.map +1 -1
  183. package/dist/index.d.ts +17 -10
  184. package/dist/index.js +57 -2
  185. package/dist/index.js.map +1 -1
  186. package/dist/{kit-labels-D1J2_jyD.d.ts → kit-labels-R8mIAc9N.d.ts} +811 -8
  187. package/dist/lib/countries.d.ts +43 -0
  188. package/dist/lib/countries.js +39 -0
  189. package/dist/lib/countries.js.map +1 -0
  190. package/dist/lib/decimal-marks.d.ts +46 -0
  191. package/dist/lib/decimal-marks.js +40 -0
  192. package/dist/lib/decimal-marks.js.map +1 -0
  193. package/dist/lib/iban.d.ts +98 -0
  194. package/dist/lib/iban.js +154 -0
  195. package/dist/lib/iban.js.map +1 -0
  196. package/dist/lib/isin.d.ts +30 -0
  197. package/dist/lib/isin.js +36 -0
  198. package/dist/lib/isin.js.map +1 -0
  199. package/dist/lib/phone.d.ts +69 -0
  200. package/dist/lib/phone.js +121 -0
  201. package/dist/lib/phone.js.map +1 -0
  202. package/dist/rhf/fields.d.ts +148 -11
  203. package/dist/rhf/fields.js +332 -3
  204. package/dist/rhf/fields.js.map +1 -1
  205. package/dist/rhf/form.d.ts +10 -8
  206. package/dist/rhf.d.ts +13 -9
  207. package/dist/rhf.js.map +1 -1
  208. package/dist/shell/app-shell.d.ts +10 -8
  209. package/dist/shell/top-bar-brand.d.ts +10 -8
  210. package/dist/shell.d.ts +10 -8
  211. package/dist/tour/tour.js +20 -24
  212. package/dist/tour/tour.js.map +1 -1
  213. package/dist/wizard/stepper-nav.d.ts +10 -8
  214. package/dist/wizard.d.ts +10 -8
  215. package/package.json +1 -1
  216. package/src/components/account-chips.tsx +17 -3
  217. package/src/components/account-settings.tsx +92 -26
  218. package/src/components/amount-input.tsx +49 -56
  219. package/src/components/autocomplete.tsx +133 -110
  220. package/src/components/checkbox-group.tsx +229 -0
  221. package/src/components/checkbox.tsx +72 -8
  222. package/src/components/chip.tsx +80 -10
  223. package/src/components/combobox.tsx +292 -253
  224. package/src/components/country-select.tsx +443 -0
  225. package/src/components/currency-select.tsx +109 -33
  226. package/src/components/danger-confirm.tsx +345 -77
  227. package/src/components/data-table-labels.ts +16 -1
  228. package/src/components/data-table.tsx +175 -6
  229. package/src/components/date-picker.tsx +173 -62
  230. package/src/components/entity-combobox.tsx +107 -87
  231. package/src/components/field-anatomy.tsx +190 -0
  232. package/src/components/field-parts.tsx +184 -0
  233. package/src/components/file-button.tsx +36 -3
  234. package/src/components/file-dropzone.tsx +106 -38
  235. package/src/components/form-actions.tsx +115 -2
  236. package/src/components/iban-input.tsx +231 -0
  237. package/src/components/icon-picker.tsx +26 -4
  238. package/src/components/inline-edit-field.tsx +533 -0
  239. package/src/components/language-select.tsx +78 -0
  240. package/src/components/line-items.tsx +35 -2
  241. package/src/components/list.tsx +55 -6
  242. package/src/components/money-field.tsx +10 -0
  243. package/src/components/month-picker.tsx +399 -63
  244. package/src/components/multi-select.tsx +80 -58
  245. package/src/components/number-field.tsx +28 -46
  246. package/src/components/number-input.tsx +210 -43
  247. package/src/components/one-time-code-input.tsx +288 -0
  248. package/src/components/phone-input.tsx +299 -0
  249. package/src/components/sign-chip.tsx +103 -0
  250. package/src/components/swatch-picker.tsx +118 -30
  251. package/src/components/switch.tsx +58 -7
  252. package/src/components/tile-radio.tsx +71 -16
  253. package/src/components/time-input.tsx +8 -1
  254. package/src/components/toggle-group.tsx +206 -73
  255. package/src/components/ui.tsx +472 -105
  256. package/src/data-table.ts +2 -1
  257. package/src/feedback/feedback-attachment.tsx +313 -9
  258. package/src/feedback/feedback-dialog.tsx +2 -0
  259. package/src/feedback/feedback-thread.tsx +66 -5
  260. package/src/i18n/defaults.ts +16 -1
  261. package/src/i18n/german.ts +47 -0
  262. package/src/i18n/kit-labels.tsx +78 -2
  263. package/src/i18n/locales/en.ts +10 -0
  264. package/src/i18n/locales/es.ts +46 -0
  265. package/src/i18n/locales/fr.ts +45 -0
  266. package/src/i18n/locales/hu.ts +46 -0
  267. package/src/i18n/locales/it.ts +47 -0
  268. package/src/i18n/locales/zh.ts +44 -0
  269. package/src/i18n/review.ts +7 -0
  270. package/src/index.ts +53 -3
  271. package/src/lib/countries.ts +91 -0
  272. package/src/lib/decimal-marks.ts +99 -0
  273. package/src/lib/iban.ts +171 -0
  274. package/src/lib/isin.ts +62 -0
  275. package/src/lib/phone.ts +211 -0
  276. package/src/rhf/fields.tsx +606 -9
  277. package/src/rhf.ts +2 -1
  278. package/src/tour/tour.tsx +66 -28
@@ -1,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,15 @@ 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
+ type FieldHintParts,
21
+ } from "./field-parts";
13
22
 
14
23
  export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger" | "brand" | "link";
15
24
 
@@ -1840,6 +1849,149 @@ export function resolvePasswordRevealLabels(
1840
1849
  return { ...DEFAULT_PASSWORD_REVEAL_LABELS, ...partial };
1841
1850
  }
1842
1851
 
1852
+ /**
1853
+ * What the character counter of {@link Input} and {@link Textarea} (`showCount`) says.
1854
+ * The visible "12/80" is digits and a slash and is not translated; these are the words
1855
+ * a screen reader gets instead of it.
1856
+ */
1857
+ export interface CharacterCountLabels {
1858
+ /** The counter as part of the field's description, read when focus reaches the
1859
+ * field: "12 of 80 characters". */
1860
+ count: (used: number, max: number) => string;
1861
+ /** Said once, politely, on entering the last stretch before the limit: "8
1862
+ * characters left". */
1863
+ remaining: (left: number) => string;
1864
+ /** Said once, politely, on reaching the limit. */
1865
+ limitReached: string;
1866
+ }
1867
+
1868
+ export const DEFAULT_CHARACTER_COUNT_LABELS: CharacterCountLabels = {
1869
+ count: (used, max) => `${used} of ${max} characters`,
1870
+ remaining: (left) => (left === 1 ? "1 character left" : `${left} characters left`),
1871
+ limitReached: "Character limit reached",
1872
+ };
1873
+
1874
+ /** The length the browser's own `maxLength` counts — UTF-16 code units — so the counter
1875
+ * and the limit can never disagree about whether a field is full. */
1876
+ function textLength(value: unknown): number {
1877
+ return value === undefined || value === null ? 0 : String(value).length;
1878
+ }
1879
+
1880
+ /** The counter's length: the controlled `value`'s, else what the field has been typed
1881
+ * to (from `defaultValue` on). `track` is called from the field's change handler. */
1882
+ function useTypedLength(value: unknown, defaultValue: unknown) {
1883
+ const [typed, setTyped] = useState(() => textLength(defaultValue));
1884
+ return { used: value !== undefined ? textLength(value) : typed, track: (text: string) => setTyped(text.length) };
1885
+ }
1886
+
1887
+ type CountZone = "ok" | "near" | "limit";
1888
+
1889
+ /** The last stretch before the limit: a tenth of it, at least 1 and at most 20 — "8
1890
+ * left" of 80, and 20 of 2,000 rather than 200, which is not "near" in any sense a
1891
+ * person typing means. */
1892
+ function nearLimitAt(max: number): number {
1893
+ return Math.min(20, Math.max(1, Math.ceil(max * 0.1)));
1894
+ }
1895
+
1896
+ function countZone(used: number, max: number): CountZone {
1897
+ if (used >= max) return "limit";
1898
+ return max - used <= nearLimitAt(max) ? "near" : "ok";
1899
+ }
1900
+
1901
+ /**
1902
+ * The live "12/80" under a field with `showCount` (keksdose K11: the admin broadcast,
1903
+ * support replies, canned replies and the handoff note each counted by hand, and one
1904
+ * announced every keystroke).
1905
+ *
1906
+ * Three channels, each saying only what it should:
1907
+ * - the digits, `aria-hidden` — "12 slash 80" is not a sentence;
1908
+ * - a `hidden` "12 of 80 characters" on the field's `aria-describedby`, so the count is
1909
+ * read with the field when focus reaches it, and never while typing;
1910
+ * - a polite live region that speaks ONCE per stretch: on entering the last tenth
1911
+ * ("8 characters left") and on reaching the limit. A region that changed on every
1912
+ * keystroke would talk over the user's own typing echo for the whole message.
1913
+ * Nothing is said on mount: a stored note already near its limit is not news.
1914
+ */
1915
+ function CharacterCount({
1916
+ id,
1917
+ used,
1918
+ max,
1919
+ labels,
1920
+ caption,
1921
+ }: {
1922
+ id: string;
1923
+ used: number;
1924
+ max: number;
1925
+ labels: CharacterCountLabels;
1926
+ caption: ReactNode;
1927
+ }) {
1928
+ const zone = countZone(used, max);
1929
+ // What the live region holds, re-set only when the stretch changes — the previous
1930
+ // render's zone kept in state, the pattern React documents for "adjusting state when
1931
+ // a prop changes", so the region's text (and so the announcement) changes at most
1932
+ // once per crossing.
1933
+ const [said, setSaid] = useState<{ zone: CountZone; text: string }>({ zone, text: "" });
1934
+ if (said.zone !== zone) {
1935
+ setSaid({
1936
+ zone,
1937
+ text: zone === "near" ? labels.remaining(max - used) : zone === "limit" ? labels.limitReached : "",
1938
+ });
1939
+ }
1940
+ return (
1941
+ // `relative` holds the sr-only region (see sr-only-containment.test).
1942
+ <div className="relative mt-1 flex items-start gap-2">
1943
+ <div className="min-w-0 flex-1">{caption}</div>
1944
+ <span
1945
+ aria-hidden
1946
+ className={cn(
1947
+ "shrink-0 text-[11px] leading-tight tabular-nums",
1948
+ zone === "limit"
1949
+ ? "text-[var(--danger)]"
1950
+ : zone === "near"
1951
+ ? "text-[var(--warning)]"
1952
+ : "text-[var(--text-muted)]",
1953
+ )}
1954
+ >
1955
+ {used}/{max}
1956
+ </span>
1957
+ <span id={id} hidden>
1958
+ {labels.count(used, max)}
1959
+ </span>
1960
+ <span role="status" className="sr-only">
1961
+ {said.text}
1962
+ </span>
1963
+ </div>
1964
+ );
1965
+ }
1966
+
1967
+ /** What goes under a text field: the caption (beside the counter, when there is one),
1968
+ * then the error. `null` when there is nothing, so a field with none keeps exactly the
1969
+ * DOM it had (see {@link FieldGroup}). */
1970
+ function fieldBelow(hint: FieldHintParts, counter: ReactNode, errorEl: ReactNode): ReactNode {
1971
+ if (counter === null && hint.captionText === undefined && errorEl === null) return null;
1972
+ return (
1973
+ <>
1974
+ {counter ?? <FieldCaption parts={hint} />}
1975
+ {errorEl}
1976
+ </>
1977
+ );
1978
+ }
1979
+
1980
+ /**
1981
+ * kastlan 8: a caller's placeholder on a LABELLED field, shown once the label has
1982
+ * floated out of its way — on focus — and transparent until then, while the label sits
1983
+ * where the placeholder would be. Without it the field forced `placeholder=" "` for the
1984
+ * float trick and dropped the caller's text without a word (kastlan's "note" field lost
1985
+ * its hint). `:placeholder-shown` still drives the float: it matches an empty field
1986
+ * whatever the placeholder's text is.
1987
+ */
1988
+ const FLOATING_PLACEHOLDER_ON_FOCUS = "focus:placeholder:text-[var(--text-placeholder)]";
1989
+
1990
+ /** A placeholder with something to show. `""` and blanks are the float trick's own
1991
+ * `" "`, never the caller's hint. */
1992
+ const hasPlaceholderText = (placeholder: string | undefined): placeholder is string =>
1993
+ placeholder !== undefined && placeholder.trim() !== "";
1994
+
1843
1995
  // Native date/time inputs only reveal the calendar via the tiny trailing icon;
1844
1996
  // open the picker on a click anywhere in the field instead (feedback #224).
1845
1997
  const PICKER_TYPES = new Set(["date", "datetime-local", "month", "time", "week"]);
@@ -1874,12 +2026,42 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
1874
2026
  * `invalid`, so the field paints as well as announces. `invalid` alone still
1875
2027
  * covers the case where the message lives elsewhere. See {@link useFieldError}. */
1876
2028
  error?: ReactNode;
2029
+ /**
2030
+ * Standing advice for the field (keksdose K4: Input had `error` but no `hint`, so the
2031
+ * app built the caption by hand under each field, and half of those never reached a
2032
+ * screen reader). The {@link Select} rule, so every field reads alike:
2033
+ * - plain TEXT (a string or a number) is a caption UNDER the field, attached with
2034
+ * `aria-describedby` — after the caller's own ids, before the error's;
2035
+ * - anything else (a {@link FieldHint} "?") rides the label line beside the
2036
+ * animated label, as NumberInput's does — or, on a field with no label, sits at the
2037
+ * field's end edge outside the box, and `className` then styles that row.
2038
+ */
2039
+ hint?: ReactNode;
2040
+ /**
2041
+ * A live "12/80" under the field, with `maxLength` (keksdose K11 — the broadcast,
2042
+ * support and handoff-note fields counted by hand). Read with the field as "12 of 80
2043
+ * characters", and announced politely only on entering the last stretch and on
2044
+ * reaching the limit — never on every keystroke. See {@link CharacterCount}. Ignored
2045
+ * without a `maxLength`: a count with no limit is not what this answers. Counts
2046
+ * UTF-16 code units, as `maxLength` itself does. Controlled, it counts `value`;
2047
+ * uncontrolled, `defaultValue` and then every change event — a value written from
2048
+ * outside without one (a form library's `reset`) shows from the next keystroke.
2049
+ */
2050
+ showCount?: boolean;
2051
+ /** The counter's words. See {@link CharacterCountLabels}. */
2052
+ countLabels?: Partial<CharacterCountLabels>;
1877
2053
  /** Names for the password reveal toggle, English by default — it is the one
1878
2054
  * string this component renders on its own behalf, and a German form was
1879
2055
  * reading it out in English. See {@link PasswordRevealLabels}. */
1880
2056
  passwordLabels?: Partial<PasswordRevealLabels>;
1881
2057
  }
1882
2058
 
2059
+ /** The counter's words: the `characterCount` namespace — prop > `<UiKitProvider
2060
+ * labels>` > English. One place, so the namespace is named once. */
2061
+ function useCharacterCountLabels(prop: Partial<CharacterCountLabels> | undefined): CharacterCountLabels {
2062
+ return useKitLabels("characterCount", DEFAULT_CHARACTER_COUNT_LABELS, prop);
2063
+ }
2064
+
1883
2065
  export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, ref) {
1884
2066
  const {
1885
2067
  className,
@@ -1891,19 +2073,47 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(pro
1891
2073
  variant = "field",
1892
2074
  invalid,
1893
2075
  error,
2076
+ hint,
2077
+ showCount,
2078
+ countLabels,
1894
2079
  passwordLabels,
1895
2080
  ...rest
1896
2081
  } = props;
1897
- // Passed at all — even as `undefined` — the field keeps its box; see FieldGroup.
1898
- const reserve = "error" in props;
2082
+ // Passed at all — even as `undefined` — the field keeps its box; see FieldGroup. The
2083
+ // same for `hint` and `showCount` (keksdose, 0.22): a caption coming and going under
2084
+ // the control must not rebuild it, or focus and the caret are lost mid-edit.
2085
+ const reserve = "error" in props || "hint" in props || "showCount" in props;
1899
2086
  const generated = useId();
1900
2087
  const fieldId = id ?? generated;
2088
+ const countId = useId();
2089
+ const hintParts = useFieldHint(hint, rest["aria-describedby"]);
2090
+ const maxLength = rest.maxLength;
2091
+ const counting = Boolean(showCount) && typeof maxLength === "number" && maxLength > 0;
2092
+ const length = useTypedLength(rest.value, rest.defaultValue);
2093
+ const countText = useCharacterCountLabels(countLabels);
1901
2094
  const { isInvalid, describedBy, errorEl } = useFieldError(
1902
2095
  error,
1903
2096
  invalid,
1904
- rest["aria-describedby"],
2097
+ // Caller's, caption, count — then the error, appended by the hook.
2098
+ mergeDescribedBy(hintParts.describedBy, counting && countId),
1905
2099
  rest["aria-invalid"],
1906
2100
  );
2101
+ const counter = counting ? (
2102
+ <CharacterCount
2103
+ id={countId}
2104
+ used={length.used}
2105
+ max={maxLength}
2106
+ labels={countText}
2107
+ caption={<FieldCaption parts={hintParts} className="mt-0" />}
2108
+ />
2109
+ ) : null;
2110
+ const below = fieldBelow(hintParts, counter, errorEl);
2111
+ const onChange = counting
2112
+ ? (e: ChangeEvent<HTMLInputElement>) => {
2113
+ length.track(e.target.value);
2114
+ rest.onChange?.(e);
2115
+ }
2116
+ : rest.onChange;
1907
2117
  const asDisplay = useMediaQuery(PHONE_QUERY, false) && variant === "display";
1908
2118
  // Password fields get a reveal toggle so users can check what they typed.
1909
2119
  const isPassword = type === "password";
@@ -1943,66 +2153,92 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(pro
1943
2153
  </button>
1944
2154
  ) : null;
1945
2155
  if (label === undefined) {
2156
+ // A "?" with no label line to ride goes at the end edge, outside the box, and
2157
+ // `className` moves to that row (see `hint`); without one nothing moves.
2158
+ const endHint = hintParts.labelHint;
2159
+ const ownClass = endHint === undefined ? className : undefined;
1946
2160
  if (!isPassword) {
1947
2161
  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
- />
2162
+ <FieldGroup errorEl={below} reserve={reserve}>
2163
+ <EndHintRow hint={endHint} className={className}>
2164
+ <input
2165
+ ref={ref}
2166
+ id={id}
2167
+ type={type}
2168
+ placeholder={placeholder}
2169
+ {...rest}
2170
+ // AFTER the spread, so the prop wins — but OR-ed with whatever the spread
2171
+ // carried, or setting `invalid` would have quietly deleted a caller's own
2172
+ // `aria-invalid`. The prop is the one that also paints; a bare attribute
2173
+ // still announces, which is all it ever did.
2174
+ aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2175
+ // Likewise merged rather than replaced — see {@link useFieldError}.
2176
+ aria-describedby={describedBy}
2177
+ onClick={handleClick}
2178
+ onChange={onChange}
2179
+ className={cn(FIELD_BASE, ownClass, inputClassName, isInvalid && FIELD_INVALID)}
2180
+ />
2181
+ </EndHintRow>
1965
2182
  </FieldGroup>
1966
2183
  );
1967
2184
  }
1968
2185
  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>
2186
+ <FieldGroup errorEl={below} reserve={reserve}>
2187
+ <EndHintRow hint={endHint} className={className}>
2188
+ <div className={cn("relative", ownClass)}>
2189
+ <input
2190
+ ref={ref}
2191
+ id={id}
2192
+ type={effectiveType}
2193
+ placeholder={placeholder}
2194
+ {...rest}
2195
+ aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2196
+ aria-describedby={describedBy}
2197
+ onChange={onChange}
2198
+ className={cn(FIELD_BASE, "pe-9", inputClassName, isInvalid && FIELD_INVALID)}
2199
+ />
2200
+ {revealToggle}
2201
+ </div>
2202
+ </EndHintRow>
1983
2203
  </FieldGroup>
1984
2204
  );
1985
2205
  }
2206
+ const ownPlaceholder = hasPlaceholderText(placeholder);
1986
2207
  return (
1987
- <FieldGroup errorEl={errorEl} reserve={reserve}>
1988
- <FloatingField className={className} htmlFor={fieldId} label={label} srOnlyLabel={asDisplay}>
2208
+ <FieldGroup errorEl={below} reserve={reserve}>
2209
+ <FloatingField
2210
+ className={className}
2211
+ htmlFor={fieldId}
2212
+ label={label}
2213
+ srOnlyLabel={asDisplay}
2214
+ hint={hintParts.labelHint}
2215
+ >
1989
2216
  <input
1990
2217
  ref={ref}
1991
2218
  id={fieldId}
1992
2219
  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 : " ")) : " "}
2220
+ // A labelled field with no placeholder of its own gets a single space, which
2221
+ // feeds the floating label's peer-placeholder-shown trick. A caller's own is
2222
+ // kept and shown on focus, once the label has floated out of its way (kastlan
2223
+ // 8) — see FLOATING_PLACEHOLDER_ON_FOCUS. With the label sr-only there is no
2224
+ // float left to drive, and an empty borderless line would say nothing at all
2225
+ // — so there the label text is the placeholder when the caller gave none.
2226
+ placeholder={
2227
+ asDisplay
2228
+ ? (placeholder ?? (typeof label === "string" ? label : " "))
2229
+ : ownPlaceholder
2230
+ ? placeholder
2231
+ : " "
2232
+ }
1998
2233
  {...rest}
1999
2234
  aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2000
2235
  aria-describedby={describedBy}
2001
2236
  onClick={handleClick}
2237
+ onChange={onChange}
2002
2238
  className={cn(
2003
2239
  asDisplay
2004
2240
  ? cn(FIELD_DISPLAY, "text-xl font-semibold leading-snug")
2005
- : FLOATING_INPUT_CLASS,
2241
+ : cn(FLOATING_INPUT_CLASS, ownPlaceholder && FLOATING_PLACEHOLDER_ON_FOCUS),
2006
2242
  isPassword && "pe-9",
2007
2243
  inputClassName,
2008
2244
  isInvalid && FIELD_INVALID,
@@ -2045,6 +2281,29 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
2045
2281
  * the chevron is positioned against — so it could set a width and nothing
2046
2282
  * else; this is the way to the element. See {@link Input}'s `inputClassName`. */
2047
2283
  selectClassName?: string;
2284
+ /**
2285
+ * Why the choice cannot be changed — {@link Button}'s `disabledReason`, for a select
2286
+ * that SAVES on change (a role picker in a members table, a status in a row). keksdose
2287
+ * K3: those sat inside a hand-rolled `SaveGuard` that forced a native `disabled`, which
2288
+ * took the field out of the tab order so the reason never reached a keyboard.
2289
+ *
2290
+ * With a reason the select is `aria-disabled` instead — still focusable, still showing
2291
+ * its value — its list does not open (pointer and keys are swallowed, Tab and Escape
2292
+ * excepted), `onChange` is never called, and the reason is in the kit {@link Tooltip}
2293
+ * and on `aria-describedby`. It wears the settled look of a disabled field and drops
2294
+ * its chevron, as a disabled Select does (dev#474). It wins over `disabled`.
2295
+ *
2296
+ * Use it CONTROLLED (`value` + `onChange`): React puts a controlled value back after
2297
+ * the swallowed change. A touch platform's own picker may still open; nothing it picks
2298
+ * is kept or reported.
2299
+ */
2300
+ disabledReason?: ReactNode;
2301
+ /**
2302
+ * This select COMMITS — choosing saves. Under a locked {@link WriteLockProvider} it is
2303
+ * locked the `disabledReason` way with the lock's reason (which wins over its own).
2304
+ * No provider, or an unlocked one: no effect. Button's `commit`, for keksdose K3.
2305
+ */
2306
+ commit?: boolean;
2048
2307
  }
2049
2308
 
2050
2309
  // The compact select: the field's colours, a toolbar button's height. `py-0` and a
@@ -2052,12 +2311,37 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
2052
2311
  // height the caller's type brings with it.
2053
2312
  const SELECT_SM = "h-7 py-0 ps-2 pe-7 text-xs";
2054
2313
 
2314
+ /** Keys a locked select still answers: leaving it, and dismissing its tooltip. Every
2315
+ * other key would open the list or step the value. */
2316
+ const LOCKED_SELECT_KEYS = new Set(["Tab", "Escape", "Shift"]);
2317
+
2318
+ /** FIELD_BASE's `disabled:` look, for a select that is locked without being `disabled`
2319
+ * (the `:disabled` variants cannot match it). */
2320
+ const FIELD_LOCKED = "cursor-not-allowed bg-[var(--bg-surface-2)] text-[var(--text-muted)]";
2321
+
2055
2322
  export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(props, ref) {
2056
- const { className, label, id, children, invalid, error, hint, size, selectClassName, ...rest } =
2057
- props;
2323
+ const {
2324
+ className,
2325
+ label,
2326
+ id,
2327
+ children,
2328
+ invalid,
2329
+ error,
2330
+ hint,
2331
+ size,
2332
+ selectClassName,
2333
+ disabledReason,
2334
+ commit,
2335
+ onMouseDown,
2336
+ onKeyDown,
2337
+ onChange,
2338
+ ...rest
2339
+ } = props;
2058
2340
  // See Input's `reserve`.
2059
- const reserve = "error" in props;
2341
+ const reserve = "error" in props || "hint" in props || "showCount" in props;
2060
2342
  const generated = useId();
2343
+ const lock = useLockReason(commit, disabledReason);
2344
+ const locked = lock.locked;
2061
2345
  // Only a number reaches the DOM; the two words are this component's own.
2062
2346
  const nativeSize = typeof size === "number" ? size : undefined;
2063
2347
  // A numeric size above 1 (or `multiple`) makes the browser draw a LIST BOX — all
@@ -2076,12 +2360,9 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
2076
2360
  error,
2077
2361
  invalid,
2078
2362
  // 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"],
2363
+ // second, the order useFieldError keeps for a caller's own description. A lock's
2364
+ // reason sits between the two.
2365
+ mergeDescribedBy(rest["aria-describedby"], textHint && hintId, locked && lock.reasonId),
2085
2366
  rest["aria-invalid"],
2086
2367
  );
2087
2368
  const below =
@@ -2099,9 +2380,10 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
2099
2380
  // in from the end border and matches both themes — feedback #223. A DISABLED
2100
2381
  // select has no menu to drop, so it drops the chevron too: the arrow is the one
2101
2382
  // 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).
2383
+ // account type became read-only and still looked exactly like a picker). A locked
2384
+ // one has no menu either.
2103
2385
  const chevron =
2104
- rest.disabled || listBox ? null : (
2386
+ rest.disabled || listBox || locked ? null : (
2105
2387
  <FieldChevron className={small ? "end-1.5 size-3.5" : undefined} />
2106
2388
  );
2107
2389
  // `pe-9` is the room the chevron takes; a list box has none to make room for.
@@ -2113,6 +2395,32 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
2113
2395
  // browser sizes the box by), and the box keeps no bottom padding: exactly `size`
2114
2396
  // whole rows, at any width.
2115
2397
  const dress = listBox ? "overflow-y-auto [&_option]:py-1" : "appearance-none pe-9";
2398
+ // Locked: focusable, but nothing opens the list or moves the value, and nothing is
2399
+ // reported. The no-op `onChange` keeps a controlled select's value where React put
2400
+ // it (and keeps React from warning about a `value` with no handler).
2401
+ const interaction = locked
2402
+ ? {
2403
+ disabled: undefined,
2404
+ "aria-disabled": true as const,
2405
+ onMouseDown: (e: MouseEvent<HTMLSelectElement>) => e.preventDefault(),
2406
+ onKeyDown: (e: KeyboardEvent<HTMLSelectElement>) => {
2407
+ if (!LOCKED_SELECT_KEYS.has(e.key) && !e.ctrlKey && !e.metaKey) e.preventDefault();
2408
+ },
2409
+ onChange: () => {},
2410
+ }
2411
+ : { onMouseDown, onKeyDown, onChange };
2412
+ // While locked the field sits in the reason's Tooltip, which becomes the outermost
2413
+ // box — so `className` (a width, a grid cell) moves onto it, and the form keeps its
2414
+ // layout. `block` so a full-width field stays full width inside it.
2415
+ const boxClass = locked ? undefined : className;
2416
+ const withReason = (box: ReactNode) =>
2417
+ locked ? (
2418
+ <LockedReason lock={lock} className={cn("block", className)}>
2419
+ {box}
2420
+ </LockedReason>
2421
+ ) : (
2422
+ box
2423
+ );
2116
2424
  if (label === undefined) {
2117
2425
  const select = (
2118
2426
  <select
@@ -2126,6 +2434,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
2126
2434
  id={id}
2127
2435
  size={nativeSize}
2128
2436
  {...rest}
2437
+ {...interaction}
2129
2438
  // OR-ed with the spread for the reason spelled out on Input's copy: this
2130
2439
  // branch wrote `invalid || undefined`, so passing `aria-invalid` by hand
2131
2440
  // to a Select — which is what a caller does when the validity is
@@ -2138,6 +2447,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
2138
2447
  dress,
2139
2448
  listBox && "py-0",
2140
2449
  small && SELECT_SM,
2450
+ locked && FIELD_LOCKED,
2141
2451
  selectClassName,
2142
2452
  isInvalid && FIELD_INVALID,
2143
2453
  )}
@@ -2153,51 +2463,65 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
2153
2463
  if (hasContent(hint) && !textHint) {
2154
2464
  return (
2155
2465
  <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>
2466
+ {withReason(
2467
+ <div className={cn("flex items-center gap-1.5", boxClass)}>
2468
+ <div className="relative min-w-0 flex-1">
2469
+ {select}
2470
+ {chevron}
2471
+ </div>
2472
+ <span className="flex shrink-0 items-center">{hint}</span>
2473
+ </div>,
2474
+ )}
2163
2475
  </FieldGroup>
2164
2476
  );
2165
2477
  }
2166
2478
  return (
2167
2479
  <FieldGroup errorEl={below} reserve={reserve}>
2168
- <div className={cn("relative", className)}>
2169
- {select}
2170
- {chevron}
2171
- </div>
2480
+ {withReason(
2481
+ <div className={cn("relative", boxClass)}>
2482
+ {select}
2483
+ {chevron}
2484
+ </div>,
2485
+ )}
2172
2486
  </FieldGroup>
2173
2487
  );
2174
2488
  }
2175
2489
  return (
2176
2490
  <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
- )}
2491
+ {withReason(
2492
+ <FloatingField
2493
+ className={boxClass}
2494
+ htmlFor={fieldId}
2495
+ label={label}
2496
+ staticLabel
2497
+ hint={textHint ? undefined : hint}
2196
2498
  >
2197
- {children}
2198
- </select>
2199
- {chevron}
2200
- </FloatingField>
2499
+ <select
2500
+ ref={ref}
2501
+ id={fieldId}
2502
+ size={nativeSize}
2503
+ {...rest}
2504
+ {...interaction}
2505
+ aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2506
+ aria-describedby={describedBy}
2507
+ className={cn(
2508
+ FIELD_BASE,
2509
+ // The floated label takes the top strip of a list box too, so its first
2510
+ // row starts under the label rather than behind it. No bottom padding — see
2511
+ // `dress`.
2512
+ listBox ? "pt-5 pb-0" : FIELD_FLOATING_PAD,
2513
+ "peer",
2514
+ dress,
2515
+ locked && FIELD_LOCKED,
2516
+ selectClassName,
2517
+ isInvalid && FIELD_INVALID,
2518
+ )}
2519
+ >
2520
+ {children}
2521
+ </select>
2522
+ {chevron}
2523
+ </FloatingField>,
2524
+ )}
2201
2525
  </FieldGroup>
2202
2526
  );
2203
2527
  });
@@ -2209,46 +2533,89 @@ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElemen
2209
2533
  invalid?: boolean;
2210
2534
  /** See {@link Input}'s `error`. */
2211
2535
  error?: ReactNode;
2536
+ /** See {@link Input}'s `hint` (keksdose K4): text is a caption under the field, a
2537
+ * {@link FieldHint} rides the label line (or, unlabelled, the end edge). */
2538
+ hint?: ReactNode;
2539
+ /** See {@link Input}'s `showCount` (keksdose K11): a live "12/80" under the field,
2540
+ * with `maxLength`, announced only near and at the limit. */
2541
+ showCount?: boolean;
2542
+ /** The counter's words. See {@link CharacterCountLabels}. */
2543
+ countLabels?: Partial<CharacterCountLabels>;
2212
2544
  }
2213
2545
 
2214
2546
  export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(props, ref) {
2215
- const { className, label, id, placeholder, invalid, error, ...rest } = props;
2547
+ const { className, label, id, placeholder, invalid, error, hint, showCount, countLabels, ...rest } = props;
2216
2548
  // See Input's `reserve`.
2217
- const reserve = "error" in props;
2549
+ const reserve = "error" in props || "hint" in props || "showCount" in props;
2218
2550
  const generated = useId();
2219
2551
  const fieldId = id ?? generated;
2552
+ const countId = useId();
2553
+ const hintParts = useFieldHint(hint, rest["aria-describedby"]);
2554
+ const maxLength = rest.maxLength;
2555
+ const counting = Boolean(showCount) && typeof maxLength === "number" && maxLength > 0;
2556
+ const length = useTypedLength(rest.value, rest.defaultValue);
2557
+ const countText = useCharacterCountLabels(countLabels);
2220
2558
  const { isInvalid, describedBy, errorEl } = useFieldError(
2221
2559
  error,
2222
2560
  invalid,
2223
- rest["aria-describedby"],
2561
+ mergeDescribedBy(hintParts.describedBy, counting && countId),
2224
2562
  rest["aria-invalid"],
2225
2563
  );
2564
+ const counter = counting ? (
2565
+ <CharacterCount
2566
+ id={countId}
2567
+ used={length.used}
2568
+ max={maxLength}
2569
+ labels={countText}
2570
+ caption={<FieldCaption parts={hintParts} className="mt-0" />}
2571
+ />
2572
+ ) : null;
2573
+ const below = fieldBelow(hintParts, counter, errorEl);
2574
+ const onChange = counting
2575
+ ? (e: ChangeEvent<HTMLTextAreaElement>) => {
2576
+ length.track(e.target.value);
2577
+ rest.onChange?.(e);
2578
+ }
2579
+ : rest.onChange;
2226
2580
  if (label === undefined) {
2581
+ const endHint = hintParts.labelHint;
2227
2582
  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
- />
2583
+ <FieldGroup errorEl={below} reserve={reserve}>
2584
+ <EndHintRow hint={endHint} className={className}>
2585
+ <textarea
2586
+ ref={ref}
2587
+ id={id}
2588
+ placeholder={placeholder}
2589
+ {...rest}
2590
+ aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2591
+ aria-describedby={describedBy}
2592
+ onChange={onChange}
2593
+ className={cn(FIELD_BASE, endHint === undefined && className, isInvalid && FIELD_INVALID)}
2594
+ />
2595
+ </EndHintRow>
2238
2596
  </FieldGroup>
2239
2597
  );
2240
2598
  }
2599
+ const ownPlaceholder = hasPlaceholderText(placeholder);
2241
2600
  return (
2242
- <FieldGroup errorEl={errorEl} reserve={reserve}>
2243
- <FloatingField className={className} htmlFor={fieldId} label={label}>
2601
+ <FieldGroup errorEl={below} reserve={reserve}>
2602
+ <FloatingField className={className} htmlFor={fieldId} label={label} hint={hintParts.labelHint}>
2244
2603
  <textarea
2245
2604
  ref={ref}
2246
2605
  id={fieldId}
2247
- placeholder=" "
2606
+ // The float trick's single space, unless the caller has a placeholder of its
2607
+ // own — then that, shown on focus once the label is out of its way (kastlan
2608
+ // 8, see FLOATING_PLACEHOLDER_ON_FOCUS).
2609
+ placeholder={ownPlaceholder ? placeholder : " "}
2248
2610
  {...rest}
2249
2611
  aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
2250
2612
  aria-describedby={describedBy}
2251
- className={cn(FLOATING_INPUT_CLASS, isInvalid && FIELD_INVALID)}
2613
+ onChange={onChange}
2614
+ className={cn(
2615
+ FLOATING_INPUT_CLASS,
2616
+ ownPlaceholder && FLOATING_PLACEHOLDER_ON_FOCUS,
2617
+ isInvalid && FIELD_INVALID,
2618
+ )}
2252
2619
  />
2253
2620
  </FloatingField>
2254
2621
  </FieldGroup>