@eifi1/ui-kit 0.35.0 → 0.36.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 (265) hide show
  1. package/README.md +35 -5
  2. package/dist/admin/role-select.d.ts +3 -2
  3. package/dist/admin/role-select.js +2 -0
  4. package/dist/admin/role-select.js.map +1 -1
  5. package/dist/components/amount-input.d.ts +2 -2
  6. package/dist/components/amount-input.js.map +1 -1
  7. package/dist/components/autocomplete.d.ts +2 -2
  8. package/dist/components/autocomplete.js +2 -1
  9. package/dist/components/autocomplete.js.map +1 -1
  10. package/dist/components/button-look.d.ts +1 -1
  11. package/dist/components/button-look.js +9 -4
  12. package/dist/components/button-look.js.map +1 -1
  13. package/dist/components/checkbox-group.d.ts +5 -2
  14. package/dist/components/checkbox-group.js +15 -4
  15. package/dist/components/checkbox-group.js.map +1 -1
  16. package/dist/components/column-mapper.d.ts +5 -3
  17. package/dist/components/column-mapper.js +2 -0
  18. package/dist/components/column-mapper.js.map +1 -1
  19. package/dist/components/combobox.d.ts +2 -2
  20. package/dist/components/combobox.js +2 -1
  21. package/dist/components/combobox.js.map +1 -1
  22. package/dist/components/country-select.d.ts +3 -3
  23. package/dist/components/country-select.js +3 -0
  24. package/dist/components/country-select.js.map +1 -1
  25. package/dist/components/currency-select.d.ts +3 -2
  26. package/dist/components/currency-select.js +10 -1
  27. package/dist/components/currency-select.js.map +1 -1
  28. package/dist/components/date-field.d.ts +2 -1
  29. package/dist/components/date-field.js.map +1 -1
  30. package/dist/components/date-picker.js +3 -0
  31. package/dist/components/date-picker.js.map +1 -1
  32. package/dist/components/date-range-picker.js +3 -0
  33. package/dist/components/date-range-picker.js.map +1 -1
  34. package/dist/components/entity-combobox.d.ts +2 -2
  35. package/dist/components/entity-combobox.js +2 -1
  36. package/dist/components/entity-combobox.js.map +1 -1
  37. package/dist/components/field-parts.d.ts +66 -11
  38. package/dist/components/field-parts.js +32 -10
  39. package/dist/components/field-parts.js.map +1 -1
  40. package/dist/components/field-strip.d.ts +3 -2
  41. package/dist/components/field-strip.js +2 -1
  42. package/dist/components/field-strip.js.map +1 -1
  43. package/dist/components/field.d.ts +3 -2
  44. package/dist/components/field.js +24 -15
  45. package/dist/components/field.js.map +1 -1
  46. package/dist/components/help-popover.d.ts +9 -7
  47. package/dist/components/help-popover.js.map +1 -1
  48. package/dist/components/icon-picker.d.ts +3 -2
  49. package/dist/components/icon-picker.js +3 -0
  50. package/dist/components/icon-picker.js.map +1 -1
  51. package/dist/components/inline-entity-combobox.d.ts +2 -2
  52. package/dist/components/inline-entity-combobox.js +2 -1
  53. package/dist/components/inline-entity-combobox.js.map +1 -1
  54. package/dist/components/input.d.ts +2 -2
  55. package/dist/components/input.js +2 -1
  56. package/dist/components/input.js.map +1 -1
  57. package/dist/components/month-picker.d.ts +2 -1
  58. package/dist/components/month-picker.js +8 -1
  59. package/dist/components/month-picker.js.map +1 -1
  60. package/dist/components/multi-entity-combobox.d.ts +2 -2
  61. package/dist/components/multi-entity-combobox.js +2 -1
  62. package/dist/components/multi-entity-combobox.js.map +1 -1
  63. package/dist/components/multi-select.d.ts +2 -2
  64. package/dist/components/multi-select.js +3 -0
  65. package/dist/components/multi-select.js.map +1 -1
  66. package/dist/components/number-input.d.ts +2 -2
  67. package/dist/components/number-input.js.map +1 -1
  68. package/dist/components/numeric-field.d.ts +5 -2
  69. package/dist/components/numeric-field.js +4 -1
  70. package/dist/components/numeric-field.js.map +1 -1
  71. package/dist/components/one-time-code-input.d.ts +2 -1
  72. package/dist/components/one-time-code-input.js +2 -1
  73. package/dist/components/one-time-code-input.js.map +1 -1
  74. package/dist/components/phone-input.d.ts +1 -1
  75. package/dist/components/phone-input.js +4 -0
  76. package/dist/components/phone-input.js.map +1 -1
  77. package/dist/components/popover.js +1 -1
  78. package/dist/components/popover.js.map +1 -1
  79. package/dist/components/select.d.ts +4 -3
  80. package/dist/components/select.js +2 -1
  81. package/dist/components/select.js.map +1 -1
  82. package/dist/components/slider.d.ts +3 -2
  83. package/dist/components/slider.js +7 -1
  84. package/dist/components/slider.js.map +1 -1
  85. package/dist/components/stat-tile.js +2 -11
  86. package/dist/components/stat-tile.js.map +1 -1
  87. package/dist/components/swatch-picker.d.ts +3 -2
  88. package/dist/components/swatch-picker.js +3 -0
  89. package/dist/components/swatch-picker.js.map +1 -1
  90. package/dist/components/text-link.js +3 -1
  91. package/dist/components/text-link.js.map +1 -1
  92. package/dist/components/textarea.d.ts +2 -1
  93. package/dist/components/textarea.js +9 -3
  94. package/dist/components/textarea.js.map +1 -1
  95. package/dist/components/toggle-group.d.ts +2 -2
  96. package/dist/components/toggle-group.js +7 -3
  97. package/dist/components/toggle-group.js.map +1 -1
  98. package/dist/eslint/index.js +1 -1
  99. package/dist/i18n/defaults.js +5 -1
  100. package/dist/i18n/defaults.js.map +1 -1
  101. package/dist/i18n/german.js +97 -1
  102. package/dist/i18n/german.js.map +1 -1
  103. package/dist/i18n/kit-labels.d.ts +15 -0
  104. package/dist/i18n/kit-labels.js +21 -1
  105. package/dist/i18n/kit-labels.js.map +1 -1
  106. package/dist/i18n/locales/en.js +103 -2
  107. package/dist/i18n/locales/en.js.map +3 -3
  108. package/dist/i18n/locales/es.js +95 -1
  109. package/dist/i18n/locales/es.js.map +1 -1
  110. package/dist/i18n/locales/fr.js +96 -1
  111. package/dist/i18n/locales/fr.js.map +1 -1
  112. package/dist/i18n/locales/hu.js +95 -1
  113. package/dist/i18n/locales/hu.js.map +1 -1
  114. package/dist/i18n/locales/it.js +96 -1
  115. package/dist/i18n/locales/it.js.map +1 -1
  116. package/dist/i18n/locales/zh.js +95 -1
  117. package/dist/i18n/locales/zh.js.map +1 -1
  118. package/dist/i18n/review.js +13 -0
  119. package/dist/i18n/review.js.map +1 -1
  120. package/dist/i18n/shared-labels.js +3 -1
  121. package/dist/i18n/shared-labels.js.map +1 -1
  122. package/dist/index.d.ts +21 -0
  123. package/dist/index.js +38 -0
  124. package/dist/index.js.map +1 -1
  125. package/dist/lib/device-settings.d.ts +72 -0
  126. package/dist/lib/device-settings.js +64 -0
  127. package/dist/lib/device-settings.js.map +1 -0
  128. package/dist/lib/plain-text.d.ts +11 -0
  129. package/dist/lib/plain-text.js +16 -0
  130. package/dist/lib/plain-text.js.map +1 -0
  131. package/dist/lib/safe-storage.d.ts +6 -0
  132. package/dist/lib/safe-storage.js +11 -0
  133. package/dist/lib/safe-storage.js.map +1 -1
  134. package/dist/notifications/notification-parts.d.ts +39 -0
  135. package/dist/notifications/notification-parts.js +47 -0
  136. package/dist/notifications/notification-parts.js.map +1 -0
  137. package/dist/notifications/notification-types-setting.d.ts +62 -0
  138. package/dist/notifications/notification-types-setting.js +192 -0
  139. package/dist/notifications/notification-types-setting.js.map +1 -0
  140. package/dist/notifications/notification-types.d.ts +75 -0
  141. package/dist/notifications/notification-types.js +2 -0
  142. package/dist/notifications/notification-types.js.map +1 -0
  143. package/dist/notifications/notifications-labels.d.ts +131 -0
  144. package/dist/notifications/notifications-labels.js +83 -0
  145. package/dist/notifications/notifications-labels.js.map +1 -0
  146. package/dist/notifications/push-device-setting.d.ts +37 -0
  147. package/dist/notifications/push-device-setting.js +164 -0
  148. package/dist/notifications/push-device-setting.js.map +1 -0
  149. package/dist/notifications/push-devices-setting.d.ts +44 -0
  150. package/dist/notifications/push-devices-setting.js +203 -0
  151. package/dist/notifications/push-devices-setting.js.map +1 -0
  152. package/dist/notifications/quiet-hours-setting.d.ts +47 -0
  153. package/dist/notifications/quiet-hours-setting.js +131 -0
  154. package/dist/notifications/quiet-hours-setting.js.map +1 -0
  155. package/dist/notifications/time-zone-select.d.ts +50 -0
  156. package/dist/notifications/time-zone-select.js +76 -0
  157. package/dist/notifications/time-zone-select.js.map +1 -0
  158. package/dist/notifications/unsubscribe-panel.d.ts +53 -0
  159. package/dist/notifications/unsubscribe-panel.js +139 -0
  160. package/dist/notifications/unsubscribe-panel.js.map +1 -0
  161. package/dist/notifications/use-push-device.d.ts +122 -0
  162. package/dist/notifications/use-push-device.js +230 -0
  163. package/dist/notifications/use-push-device.js.map +1 -0
  164. package/dist/rhf/fields.d.ts +26 -19
  165. package/dist/rhf/fields.js +51 -22
  166. package/dist/rhf/fields.js.map +1 -1
  167. package/dist/settings/device-reset-setting-labels.d.ts +3 -0
  168. package/dist/settings/device-reset-setting-labels.js +15 -0
  169. package/dist/settings/device-reset-setting-labels.js.map +1 -0
  170. package/dist/settings/device-reset-setting.d.ts +58 -0
  171. package/dist/settings/device-reset-setting.js +51 -0
  172. package/dist/settings/device-reset-setting.js.map +1 -0
  173. package/dist/shell/app-shell.js +5 -0
  174. package/dist/shell/app-shell.js.map +1 -1
  175. package/dist/theme/contrast-tokens.d.ts +42 -4
  176. package/dist/theme/contrast-tokens.js +56 -9
  177. package/dist/theme/contrast-tokens.js.map +1 -1
  178. package/dist/theme/contrast.d.ts +4 -2
  179. package/dist/theme/contrast.js +13 -0
  180. package/dist/theme/contrast.js.map +1 -1
  181. package/dist/theme/palette-derive.d.ts +15 -1
  182. package/dist/theme/palette-derive.js +37 -8
  183. package/dist/theme/palette-derive.js.map +1 -1
  184. package/dist/theme/palette-presets.d.ts +2 -1
  185. package/dist/theme/palette-presets.js +5 -5
  186. package/dist/theme/palette-presets.js.map +1 -1
  187. package/dist/theme/palette-store.js +6 -0
  188. package/dist/theme/palette-store.js.map +1 -1
  189. package/dist/theme/text-size.js +6 -0
  190. package/dist/theme/text-size.js.map +1 -1
  191. package/dist/theme/theme-store.js +6 -0
  192. package/dist/theme/theme-store.js.map +1 -1
  193. package/package.json +1 -1
  194. package/src/admin/role-select.tsx +4 -1
  195. package/src/components/amount-input.tsx +2 -1
  196. package/src/components/autocomplete.tsx +5 -2
  197. package/src/components/button-look.ts +15 -5
  198. package/src/components/checkbox-group.tsx +22 -4
  199. package/src/components/column-mapper.tsx +6 -2
  200. package/src/components/combobox.tsx +5 -2
  201. package/src/components/country-select.tsx +6 -1
  202. package/src/components/currency-select.tsx +12 -3
  203. package/src/components/date-field.tsx +2 -2
  204. package/src/components/date-picker.tsx +3 -0
  205. package/src/components/date-range-picker.tsx +3 -0
  206. package/src/components/entity-combobox.tsx +5 -2
  207. package/src/components/field-parts.tsx +107 -19
  208. package/src/components/field-strip.tsx +4 -3
  209. package/src/components/field.tsx +28 -15
  210. package/src/components/help-popover.tsx +9 -7
  211. package/src/components/icon-picker.tsx +8 -3
  212. package/src/components/inline-entity-combobox.tsx +5 -2
  213. package/src/components/input.tsx +4 -2
  214. package/src/components/month-picker.tsx +10 -3
  215. package/src/components/multi-entity-combobox.tsx +5 -2
  216. package/src/components/multi-select.tsx +6 -1
  217. package/src/components/number-input.tsx +2 -2
  218. package/src/components/numeric-field.ts +10 -2
  219. package/src/components/one-time-code-input.tsx +5 -3
  220. package/src/components/phone-input.tsx +4 -0
  221. package/src/components/popover.tsx +3 -1
  222. package/src/components/select.tsx +6 -3
  223. package/src/components/slider.tsx +10 -3
  224. package/src/components/stat-tile.tsx +3 -18
  225. package/src/components/swatch-picker.tsx +9 -4
  226. package/src/components/text-link.tsx +3 -1
  227. package/src/components/textarea.tsx +11 -4
  228. package/src/components/toggle-group.tsx +21 -5
  229. package/src/i18n/defaults.ts +4 -0
  230. package/src/i18n/german.ts +113 -0
  231. package/src/i18n/kit-labels.tsx +51 -1
  232. package/src/i18n/locales/en.ts +6 -0
  233. package/src/i18n/locales/es.ts +106 -0
  234. package/src/i18n/locales/fr.ts +106 -0
  235. package/src/i18n/locales/hu.ts +104 -0
  236. package/src/i18n/locales/it.ts +105 -0
  237. package/src/i18n/locales/zh.ts +96 -0
  238. package/src/i18n/review.ts +13 -0
  239. package/src/i18n/shared-labels.ts +2 -0
  240. package/src/index.ts +50 -0
  241. package/src/lib/device-settings.ts +146 -0
  242. package/src/lib/plain-text.ts +22 -0
  243. package/src/lib/safe-storage.ts +16 -0
  244. package/src/notifications/notification-parts.tsx +83 -0
  245. package/src/notifications/notification-types-setting.tsx +309 -0
  246. package/src/notifications/notification-types.ts +80 -0
  247. package/src/notifications/notifications-labels.ts +238 -0
  248. package/src/notifications/push-device-setting.tsx +224 -0
  249. package/src/notifications/push-devices-setting.tsx +306 -0
  250. package/src/notifications/quiet-hours-setting.tsx +185 -0
  251. package/src/notifications/time-zone-select.tsx +126 -0
  252. package/src/notifications/unsubscribe-panel.tsx +203 -0
  253. package/src/notifications/use-push-device.ts +392 -0
  254. package/src/rhf/fields.tsx +77 -29
  255. package/src/settings/device-reset-setting-labels.ts +20 -0
  256. package/src/settings/device-reset-setting.tsx +119 -0
  257. package/src/shell/app-shell.tsx +8 -0
  258. package/src/theme/contrast-tokens.ts +106 -12
  259. package/src/theme/contrast.ts +30 -4
  260. package/src/theme/palette-derive.ts +68 -7
  261. package/src/theme/palette-presets.ts +9 -6
  262. package/src/theme/palette-store.ts +12 -0
  263. package/src/theme/text-size.ts +8 -0
  264. package/src/theme/theme-store.ts +10 -0
  265. package/tokens.css +38 -4
@@ -1,7 +1,7 @@
1
1
  import type { ReactNode, Ref, SelectHTMLAttributes } from "react";
2
2
  import type { CommitScope } from "./write-lock.js";
3
- import { type DisabledReasonDisplay } from "./field-parts.js";
4
- export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> {
3
+ import { type DisabledReasonDisplay, type FieldHelpProps } from "./field-parts.js";
4
+ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "size">, FieldHelpProps {
5
5
  label?: ReactNode;
6
6
  /** See {@link Input}'s `invalid`. */
7
7
  invalid?: boolean;
@@ -14,7 +14,8 @@ export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement
14
14
  * set in the value's type on top of both the label and the value. On an
15
15
  * UNLABELLED Select (no label line) a FieldHint sits at the field's end edge,
16
16
  * outside the box. Since 0.34 a FieldHint is the caption at Large and on a touch
17
- * screen, as on Input (`useInlineHint`); a `HelpPopover` stays a "?". */
17
+ * screen, as on Input (`useInlineHint`); a `HelpPopover` stays a "?". Beside a
18
+ * `help` (0.36) the hint is the caption at every size: the help's "?" is the only one. */
18
19
  hint?: ReactNode;
19
20
  /**
20
21
  * `"sm"`: a 28px, 12px-type select for a toolbar or a table header, where the
@@ -31,6 +31,7 @@ function Select(props) {
31
31
  invalid,
32
32
  error,
33
33
  hint: hintProp,
34
+ help,
34
35
  size,
35
36
  selectClassName,
36
37
  disabledReason,
@@ -49,7 +50,7 @@ function Select(props) {
49
50
  const listBox = nativeSize !== void 0 && nativeSize > 1 || Boolean(rest.multiple);
50
51
  const small = size === "sm" && label === void 0;
51
52
  const fieldId = id ?? generated;
52
- const hintParts = useFieldHint(hintProp, rest["aria-describedby"]);
53
+ const hintParts = useFieldHint(hintProp, rest["aria-describedby"], { help, name: label ?? rest["aria-label"] });
53
54
  const hint = hintParts.labelHint;
54
55
  const { isInvalid, describedBy, errorEl } = useFieldError(
55
56
  error,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/select.tsx"],"sourcesContent":["import { useId } from \"react\";\nimport type { KeyboardEvent, MouseEvent, ReactNode, Ref, SelectHTMLAttributes } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport type { CommitScope } from \"./write-lock\";\nimport {\n LockedReason,\n mergeDescribedBy,\n type DisabledReasonDisplay,\n useFieldError,\n useFieldHint,\n useLockReason,\n} from \"./field-parts\";\nimport {\n FIELD_BASE,\n FIELD_FLOATING_PAD,\n FIELD_INVALID,\n FieldChevron,\n FieldGroup,\n FloatingField,\n fieldBelow,\n} from \"./field-base\";\n\nexport interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, \"size\"> {\n label?: ReactNode;\n /** See {@link Input}'s `invalid`. */\n invalid?: boolean;\n /** See {@link Input}'s `error`. */\n error?: ReactNode;\n /** A {@link FieldHint} for the label line — see {@link FloatingField}. Plain\n * TEXT (a string or a number) is a caption instead, and goes UNDER the field\n * like every other field's hint, attached through `aria-describedby`: the label\n * line is 11px of strip shared with the label, and a sentence placed there was\n * set in the value's type on top of both the label and the value. On an\n * UNLABELLED Select (no label line) a FieldHint sits at the field's end edge,\n * outside the box. Since 0.34 a FieldHint is the caption at Large and on a touch\n * screen, as on Input (`useInlineHint`); a `HelpPopover` stays a \"?\". */\n hint?: ReactNode;\n /**\n * `\"sm\"`: a 28px, 12px-type select for a toolbar or a table header, where the\n * 36px field stands a head taller than the buttons beside it. `\"md\"` (default)\n * is the field every form uses. Unlabelled selects only — a floating label needs\n * the tall box to float in, so a labelled Select ignores `\"sm\"`.\n *\n * A NUMBER is still the native attribute (the rows of a list box) and is passed\n * straight through, so the one HTML meaning of `size` keeps working. Above 1 (as\n * with `multiple`) the browser draws a list box, and the Select drops its chevron\n * and the end padding reserved for it.\n */\n size?: \"sm\" | \"md\" | number;\n /** Classes for the `<select>` itself. `className` styles the WRAPPER — the box\n * the chevron is positioned against — so it could set a width and nothing\n * else; this is the way to the element. See {@link Input}'s `inputClassName`. */\n selectClassName?: string;\n /**\n * Why the choice cannot be changed — {@link Button}'s `disabledReason`, for a select\n * that SAVES on change (a role picker in a members table, a status in a row). keksdose\n * K3: those sat inside a hand-rolled `SaveGuard` that forced a native `disabled`, which\n * took the field out of the tab order so the reason never reached a keyboard.\n *\n * With a reason the select is `aria-disabled` instead — still focusable, still showing\n * its value — its list does not open (pointer and keys are swallowed, Tab and Escape\n * excepted), `onChange` is never called, and the reason is a line under the field at\n * Large and on a touch screen, else in the kit {@link Tooltip} (`disabledReasonDisplay`),\n * and on `aria-describedby`. It wears the settled look of a disabled field and drops\n * its chevron, as a disabled Select does (dev#474). It wins over `disabled`.\n *\n * Use it CONTROLLED (`value` + `onChange`): React puts a controlled value back after\n * the swallowed change. A touch platform's own picker may still open; nothing it picks\n * is kept or reported.\n */\n disabledReason?: ReactNode;\n /**\n * Where the reason shows — {@link Button}'s `disabledReasonDisplay`: by default a line\n * under the field at Large and on a touch screen, the tooltip otherwise (§4 \"No fact\n * only in a tooltip\"; DUP-3, refactor audit 2026-10).\n */\n disabledReasonDisplay?: DisabledReasonDisplay;\n /**\n * This select COMMITS — choosing saves. Under a locked {@link WriteLockProvider} it is\n * locked the `disabledReason` way with the lock's reason (which wins over its own).\n * No provider, or an unlocked one: no effect. Button's `commit`, for keksdose K3; a\n * {@link CommitScope} since 0.33.\n */\n commit?: CommitScope;\n /** The `<select>`. A React 19 ref prop since 0.34 — see {@link InputProps.ref}. */\n ref?: Ref<HTMLSelectElement>;\n}\n\n// The compact select: the field's colours, a toolbar button's height. `py-0` and a\n// fixed height rather than a smaller padding, so the box is 28px whatever line\n// height the caller's type brings with it.\nconst SELECT_SM = \"h-7 py-0 ps-2 pe-7 text-xs\";\n\n/** Keys a locked select still answers: leaving it, and dismissing its tooltip. Every\n * other key would open the list or step the value. */\nconst LOCKED_SELECT_KEYS = new Set([\"Tab\", \"Escape\", \"Shift\"]);\n\n/** FIELD_BASE's `disabled:` look, for a select that is locked without being `disabled`\n * (the `:disabled` variants cannot match it). */\nconst FIELD_LOCKED = \"cursor-not-allowed bg-surface-2 text-muted\";\n\nexport function Select(props: SelectProps) {\n const {\n ref,\n className,\n label,\n id,\n children,\n invalid,\n error,\n hint: hintProp,\n size,\n selectClassName,\n disabledReason,\n disabledReasonDisplay,\n commit,\n onMouseDown,\n onKeyDown,\n onChange,\n ...rest\n } = props;\n // See Input's `reserve`.\n const reserve = \"error\" in props || \"hint\" in props || \"showCount\" in props;\n const generated = useId();\n const lock = useLockReason(commit, disabledReason);\n const locked = lock.locked;\n // Only a number reaches the DOM; the two words are this component's own.\n const nativeSize = typeof size === \"number\" ? size : undefined;\n // A numeric size above 1 (or `multiple`) makes the browser draw a LIST BOX — all\n // the rows in the field, no menu to drop. The dropdown dress did not fit it: the\n // chevron was centred on a box several rows tall, sat on top of the rows' text\n // at the end edge, and `pe-9` clipped every option nine units early. A list box\n // gets the plain field instead — its own native scroll, no chevron, even padding.\n const listBox = (nativeSize !== undefined && nativeSize > 1) || Boolean(rest.multiple);\n const small = size === \"sm\" && label === undefined;\n const fieldId = id ?? generated;\n // Text is a caption under the field; a FieldHint rides the label line — and at Large\n // and on a touch screen is its words, a caption like any text (§4; a HelpPopover stays\n // on the label line, §10.23). See `hint` above. Through useFieldHint since 0.34, not a\n // copy of its rule (DUP-2, refactor audit 2026-10).\n const hintParts = useFieldHint(hintProp, rest[\"aria-describedby\"]);\n const hint = hintParts.labelHint;\n const { isInvalid, describedBy, errorEl } = useFieldError(\n error,\n invalid,\n // The caption goes in BEFORE the error — the standing advice first, the news\n // second, the order useFieldError keeps for a caller's own description. A lock's\n // reason sits between the two.\n mergeDescribedBy(hintParts.describedBy, locked && lock.reasonId),\n rest[\"aria-invalid\"],\n );\n const below = fieldBelow(hintParts, null, errorEl);\n // Custom chevron (native arrow hidden via appearance-none) so it sits a touch\n // in from the end border and matches both themes — feedback #223. A DISABLED\n // select has no menu to drop, so it drops the chevron too: the arrow is the one\n // thing on the control that promises a choice (Keksdose dev#474, where the\n // account type became read-only and still looked exactly like a picker). A locked\n // one has no menu either.\n const chevron =\n rest.disabled || listBox || locked ? null : (\n <FieldChevron className={small ? \"end-1.5 size-3.5\" : undefined} />\n );\n // `pe-9` is the room the chevron takes; a list box has none to make room for.\n //\n // A list box's height is `size` rows plus its padding, but its rows are clipped at\n // the PADDING edge, not the content edge — so FIELD_BASE's `pb-2` showed the next\n // row through the bottom padding: `size={4}` drew four and a half. The vertical room\n // moves onto the rows instead (option padding counts toward the row height the\n // browser sizes the box by), and the box keeps no bottom padding: exactly `size`\n // whole rows, at any width.\n const dress = listBox ? \"overflow-y-auto [&_option]:py-1\" : \"appearance-none pe-9\";\n // Locked: focusable, but nothing opens the list or moves the value, and nothing is\n // reported. The no-op `onChange` keeps a controlled select's value where React put\n // it (and keeps React from warning about a `value` with no handler).\n const interaction = locked\n ? {\n disabled: undefined,\n \"aria-disabled\": true as const,\n onMouseDown: (e: MouseEvent<HTMLSelectElement>) => e.preventDefault(),\n onKeyDown: (e: KeyboardEvent<HTMLSelectElement>) => {\n if (!LOCKED_SELECT_KEYS.has(e.key) && !e.ctrlKey && !e.metaKey) e.preventDefault();\n },\n onChange: () => {},\n }\n : { onMouseDown, onKeyDown, onChange };\n // While locked the field sits in the reason's frame (the line, or the Tooltip), which\n // becomes the outermost box — so `className` (a width, a grid cell) moves onto it, and\n // the form keeps its layout. `block` so a full-width field stays full width inside it.\n const boxClass = locked ? undefined : className;\n const withReason = (box: ReactNode) =>\n locked ? (\n <LockedReason lock={lock} display={disabledReasonDisplay} className={cn(\"block\", className)}>\n {box}\n </LockedReason>\n ) : (\n box\n );\n if (label === undefined) {\n const select = (\n <select\n ref={ref}\n // `id` is destructured out of the props to feed `fieldId`, and this branch\n // never put it back — so an UNLABELLED Select swallowed it and the\n // consumer's own `<label for=\"…\">` pointed at nothing. The control stayed\n // in the tab order with no accessible name at all: reachable, and silent\n // when it was reached. Input and Textarea both forward it here; this is\n // the third one doing the same thing.\n id={id}\n size={nativeSize}\n {...rest}\n {...interaction}\n // OR-ed with the spread for the reason spelled out on Input's copy: this\n // branch wrote `invalid || undefined`, so passing `aria-invalid` by hand\n // to a Select — which is what a caller does when the validity is\n // `\"grammar\"` or `\"spelling\"`, or when the paint is not wanted — had the\n // attribute silently dropped. Input has never done that.\n aria-invalid={isInvalid || rest[\"aria-invalid\"] || undefined}\n aria-describedby={describedBy}\n className={cn(\n FIELD_BASE,\n dress,\n listBox && \"py-0\",\n small && SELECT_SM,\n locked && FIELD_LOCKED,\n selectClassName,\n isInvalid && FIELD_INVALID,\n )}\n >\n {children}\n </select>\n );\n // A FieldHint with no label line to ride (0.15.5 P8): an unlabelled Select — named\n // by `aria-label` or a `<label>` of the caller's — dropped a non-text `hint`\n // without a trace, so the \"?\" a caller passed never rendered and a test looking\n // for its button found none. It goes at the end of the field instead, outside the\n // box, as the label line's own hint goes at the end of the label.\n if (hint !== undefined) {\n return (\n <FieldGroup errorEl={below} reserve={reserve}>\n {withReason(\n <div className={cn(\"flex items-center gap-1.5\", boxClass)}>\n <div className=\"relative min-w-0 flex-1\">\n {select}\n {chevron}\n </div>\n <span className=\"flex shrink-0 items-center\">{hint}</span>\n </div>,\n )}\n </FieldGroup>\n );\n }\n return (\n <FieldGroup errorEl={below} reserve={reserve}>\n {withReason(\n <div className={cn(\"relative\", boxClass)}>\n {select}\n {chevron}\n </div>,\n )}\n </FieldGroup>\n );\n }\n return (\n <FieldGroup errorEl={below} reserve={reserve}>\n {withReason(\n <FloatingField\n className={boxClass}\n htmlFor={fieldId}\n label={label}\n staticLabel\n hint={hint}\n >\n <select\n ref={ref}\n id={fieldId}\n size={nativeSize}\n {...rest}\n {...interaction}\n aria-invalid={isInvalid || rest[\"aria-invalid\"] || undefined}\n aria-describedby={describedBy}\n className={cn(\n FIELD_BASE,\n // The floated label takes the top strip of a list box too, so its first\n // row starts under the label rather than behind it. No bottom padding — see\n // `dress`.\n // At Large the label stands above the field (§10.17): no strip.\n listBox ? \"pt-5 pb-0 large:pt-2\" : FIELD_FLOATING_PAD,\n \"peer\",\n dress,\n locked && FIELD_LOCKED,\n selectClassName,\n isInvalid && FIELD_INVALID,\n )}\n >\n {children}\n </select>\n {chevron}\n </FloatingField>,\n )}\n </FieldGroup>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/select.tsx"],"sourcesContent":["import { useId } from \"react\";\nimport type { KeyboardEvent, MouseEvent, ReactNode, Ref, SelectHTMLAttributes } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport type { CommitScope } from \"./write-lock\";\nimport {\n LockedReason,\n mergeDescribedBy,\n type DisabledReasonDisplay,\n type FieldHelpProps,\n useFieldError,\n useFieldHint,\n useLockReason,\n} from \"./field-parts\";\nimport {\n FIELD_BASE,\n FIELD_FLOATING_PAD,\n FIELD_INVALID,\n FieldChevron,\n FieldGroup,\n FloatingField,\n fieldBelow,\n} from \"./field-base\";\n\nexport interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, \"size\">, FieldHelpProps {\n label?: ReactNode;\n /** See {@link Input}'s `invalid`. */\n invalid?: boolean;\n /** See {@link Input}'s `error`. */\n error?: ReactNode;\n /** A {@link FieldHint} for the label line — see {@link FloatingField}. Plain\n * TEXT (a string or a number) is a caption instead, and goes UNDER the field\n * like every other field's hint, attached through `aria-describedby`: the label\n * line is 11px of strip shared with the label, and a sentence placed there was\n * set in the value's type on top of both the label and the value. On an\n * UNLABELLED Select (no label line) a FieldHint sits at the field's end edge,\n * outside the box. Since 0.34 a FieldHint is the caption at Large and on a touch\n * screen, as on Input (`useInlineHint`); a `HelpPopover` stays a \"?\". Beside a\n * `help` (0.36) the hint is the caption at every size: the help's \"?\" is the only one. */\n hint?: ReactNode;\n /**\n * `\"sm\"`: a 28px, 12px-type select for a toolbar or a table header, where the\n * 36px field stands a head taller than the buttons beside it. `\"md\"` (default)\n * is the field every form uses. Unlabelled selects only — a floating label needs\n * the tall box to float in, so a labelled Select ignores `\"sm\"`.\n *\n * A NUMBER is still the native attribute (the rows of a list box) and is passed\n * straight through, so the one HTML meaning of `size` keeps working. Above 1 (as\n * with `multiple`) the browser draws a list box, and the Select drops its chevron\n * and the end padding reserved for it.\n */\n size?: \"sm\" | \"md\" | number;\n /** Classes for the `<select>` itself. `className` styles the WRAPPER — the box\n * the chevron is positioned against — so it could set a width and nothing\n * else; this is the way to the element. See {@link Input}'s `inputClassName`. */\n selectClassName?: string;\n /**\n * Why the choice cannot be changed — {@link Button}'s `disabledReason`, for a select\n * that SAVES on change (a role picker in a members table, a status in a row). keksdose\n * K3: those sat inside a hand-rolled `SaveGuard` that forced a native `disabled`, which\n * took the field out of the tab order so the reason never reached a keyboard.\n *\n * With a reason the select is `aria-disabled` instead — still focusable, still showing\n * its value — its list does not open (pointer and keys are swallowed, Tab and Escape\n * excepted), `onChange` is never called, and the reason is a line under the field at\n * Large and on a touch screen, else in the kit {@link Tooltip} (`disabledReasonDisplay`),\n * and on `aria-describedby`. It wears the settled look of a disabled field and drops\n * its chevron, as a disabled Select does (dev#474). It wins over `disabled`.\n *\n * Use it CONTROLLED (`value` + `onChange`): React puts a controlled value back after\n * the swallowed change. A touch platform's own picker may still open; nothing it picks\n * is kept or reported.\n */\n disabledReason?: ReactNode;\n /**\n * Where the reason shows — {@link Button}'s `disabledReasonDisplay`: by default a line\n * under the field at Large and on a touch screen, the tooltip otherwise (§4 \"No fact\n * only in a tooltip\"; DUP-3, refactor audit 2026-10).\n */\n disabledReasonDisplay?: DisabledReasonDisplay;\n /**\n * This select COMMITS — choosing saves. Under a locked {@link WriteLockProvider} it is\n * locked the `disabledReason` way with the lock's reason (which wins over its own).\n * No provider, or an unlocked one: no effect. Button's `commit`, for keksdose K3; a\n * {@link CommitScope} since 0.33.\n */\n commit?: CommitScope;\n /** The `<select>`. A React 19 ref prop since 0.34 — see {@link InputProps.ref}. */\n ref?: Ref<HTMLSelectElement>;\n}\n\n// The compact select: the field's colours, a toolbar button's height. `py-0` and a\n// fixed height rather than a smaller padding, so the box is 28px whatever line\n// height the caller's type brings with it.\nconst SELECT_SM = \"h-7 py-0 ps-2 pe-7 text-xs\";\n\n/** Keys a locked select still answers: leaving it, and dismissing its tooltip. Every\n * other key would open the list or step the value. */\nconst LOCKED_SELECT_KEYS = new Set([\"Tab\", \"Escape\", \"Shift\"]);\n\n/** FIELD_BASE's `disabled:` look, for a select that is locked without being `disabled`\n * (the `:disabled` variants cannot match it). */\nconst FIELD_LOCKED = \"cursor-not-allowed bg-surface-2 text-muted\";\n\nexport function Select(props: SelectProps) {\n const {\n ref,\n className,\n label,\n id,\n children,\n invalid,\n error,\n hint: hintProp,\n help,\n size,\n selectClassName,\n disabledReason,\n disabledReasonDisplay,\n commit,\n onMouseDown,\n onKeyDown,\n onChange,\n ...rest\n } = props;\n // See Input's `reserve`.\n const reserve = \"error\" in props || \"hint\" in props || \"showCount\" in props;\n const generated = useId();\n const lock = useLockReason(commit, disabledReason);\n const locked = lock.locked;\n // Only a number reaches the DOM; the two words are this component's own.\n const nativeSize = typeof size === \"number\" ? size : undefined;\n // A numeric size above 1 (or `multiple`) makes the browser draw a LIST BOX — all\n // the rows in the field, no menu to drop. The dropdown dress did not fit it: the\n // chevron was centred on a box several rows tall, sat on top of the rows' text\n // at the end edge, and `pe-9` clipped every option nine units early. A list box\n // gets the plain field instead — its own native scroll, no chevron, even padding.\n const listBox = (nativeSize !== undefined && nativeSize > 1) || Boolean(rest.multiple);\n const small = size === \"sm\" && label === undefined;\n const fieldId = id ?? generated;\n // Text is a caption under the field; a FieldHint rides the label line — and at Large\n // and on a touch screen is its words, a caption like any text (§4; a HelpPopover stays\n // on the label line, §10.23). See `hint` above. Through useFieldHint since 0.34, not a\n // copy of its rule (DUP-2, refactor audit 2026-10).\n const hintParts = useFieldHint(hintProp, rest[\"aria-describedby\"], { help, name: label ?? rest[\"aria-label\"] });\n const hint = hintParts.labelHint;\n const { isInvalid, describedBy, errorEl } = useFieldError(\n error,\n invalid,\n // The caption goes in BEFORE the error — the standing advice first, the news\n // second, the order useFieldError keeps for a caller's own description. A lock's\n // reason sits between the two.\n mergeDescribedBy(hintParts.describedBy, locked && lock.reasonId),\n rest[\"aria-invalid\"],\n );\n const below = fieldBelow(hintParts, null, errorEl);\n // Custom chevron (native arrow hidden via appearance-none) so it sits a touch\n // in from the end border and matches both themes — feedback #223. A DISABLED\n // select has no menu to drop, so it drops the chevron too: the arrow is the one\n // thing on the control that promises a choice (Keksdose dev#474, where the\n // account type became read-only and still looked exactly like a picker). A locked\n // one has no menu either.\n const chevron =\n rest.disabled || listBox || locked ? null : (\n <FieldChevron className={small ? \"end-1.5 size-3.5\" : undefined} />\n );\n // `pe-9` is the room the chevron takes; a list box has none to make room for.\n //\n // A list box's height is `size` rows plus its padding, but its rows are clipped at\n // the PADDING edge, not the content edge — so FIELD_BASE's `pb-2` showed the next\n // row through the bottom padding: `size={4}` drew four and a half. The vertical room\n // moves onto the rows instead (option padding counts toward the row height the\n // browser sizes the box by), and the box keeps no bottom padding: exactly `size`\n // whole rows, at any width.\n const dress = listBox ? \"overflow-y-auto [&_option]:py-1\" : \"appearance-none pe-9\";\n // Locked: focusable, but nothing opens the list or moves the value, and nothing is\n // reported. The no-op `onChange` keeps a controlled select's value where React put\n // it (and keeps React from warning about a `value` with no handler).\n const interaction = locked\n ? {\n disabled: undefined,\n \"aria-disabled\": true as const,\n onMouseDown: (e: MouseEvent<HTMLSelectElement>) => e.preventDefault(),\n onKeyDown: (e: KeyboardEvent<HTMLSelectElement>) => {\n if (!LOCKED_SELECT_KEYS.has(e.key) && !e.ctrlKey && !e.metaKey) e.preventDefault();\n },\n onChange: () => {},\n }\n : { onMouseDown, onKeyDown, onChange };\n // While locked the field sits in the reason's frame (the line, or the Tooltip), which\n // becomes the outermost box — so `className` (a width, a grid cell) moves onto it, and\n // the form keeps its layout. `block` so a full-width field stays full width inside it.\n const boxClass = locked ? undefined : className;\n const withReason = (box: ReactNode) =>\n locked ? (\n <LockedReason lock={lock} display={disabledReasonDisplay} className={cn(\"block\", className)}>\n {box}\n </LockedReason>\n ) : (\n box\n );\n if (label === undefined) {\n const select = (\n <select\n ref={ref}\n // `id` is destructured out of the props to feed `fieldId`, and this branch\n // never put it back — so an UNLABELLED Select swallowed it and the\n // consumer's own `<label for=\"…\">` pointed at nothing. The control stayed\n // in the tab order with no accessible name at all: reachable, and silent\n // when it was reached. Input and Textarea both forward it here; this is\n // the third one doing the same thing.\n id={id}\n size={nativeSize}\n {...rest}\n {...interaction}\n // OR-ed with the spread for the reason spelled out on Input's copy: this\n // branch wrote `invalid || undefined`, so passing `aria-invalid` by hand\n // to a Select — which is what a caller does when the validity is\n // `\"grammar\"` or `\"spelling\"`, or when the paint is not wanted — had the\n // attribute silently dropped. Input has never done that.\n aria-invalid={isInvalid || rest[\"aria-invalid\"] || undefined}\n aria-describedby={describedBy}\n className={cn(\n FIELD_BASE,\n dress,\n listBox && \"py-0\",\n small && SELECT_SM,\n locked && FIELD_LOCKED,\n selectClassName,\n isInvalid && FIELD_INVALID,\n )}\n >\n {children}\n </select>\n );\n // A FieldHint with no label line to ride (0.15.5 P8): an unlabelled Select — named\n // by `aria-label` or a `<label>` of the caller's — dropped a non-text `hint`\n // without a trace, so the \"?\" a caller passed never rendered and a test looking\n // for its button found none. It goes at the end of the field instead, outside the\n // box, as the label line's own hint goes at the end of the label.\n if (hint !== undefined) {\n return (\n <FieldGroup errorEl={below} reserve={reserve}>\n {withReason(\n <div className={cn(\"flex items-center gap-1.5\", boxClass)}>\n <div className=\"relative min-w-0 flex-1\">\n {select}\n {chevron}\n </div>\n <span className=\"flex shrink-0 items-center\">{hint}</span>\n </div>,\n )}\n </FieldGroup>\n );\n }\n return (\n <FieldGroup errorEl={below} reserve={reserve}>\n {withReason(\n <div className={cn(\"relative\", boxClass)}>\n {select}\n {chevron}\n </div>,\n )}\n </FieldGroup>\n );\n }\n return (\n <FieldGroup errorEl={below} reserve={reserve}>\n {withReason(\n <FloatingField\n className={boxClass}\n htmlFor={fieldId}\n label={label}\n staticLabel\n hint={hint}\n >\n <select\n ref={ref}\n id={fieldId}\n size={nativeSize}\n {...rest}\n {...interaction}\n aria-invalid={isInvalid || rest[\"aria-invalid\"] || undefined}\n aria-describedby={describedBy}\n className={cn(\n FIELD_BASE,\n // The floated label takes the top strip of a list box too, so its first\n // row starts under the label rather than behind it. No bottom padding — see\n // `dress`.\n // At Large the label stands above the field (§10.17): no strip.\n listBox ? \"pt-5 pb-0 large:pt-2\" : FIELD_FLOATING_PAD,\n \"peer\",\n dress,\n locked && FIELD_LOCKED,\n selectClassName,\n isInvalid && FIELD_INVALID,\n )}\n >\n {children}\n </select>\n {chevron}\n </FloatingField>,\n )}\n </FieldGroup>\n );\n}\n"],"mappings":"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","names":[]}
@@ -1,4 +1,5 @@
1
1
  import type { InputHTMLAttributes, ReactNode, Ref } from "react";
2
+ import { type FieldHelpProps } from "./field-parts.js";
2
3
  /**
3
4
  * Where a value sits on a logarithmic track, 0–1.
4
5
  *
@@ -17,7 +18,7 @@ export interface SliderMark {
17
18
  * technology rather than read out as a list of numbers after the slider. */
18
19
  label?: ReactNode;
19
20
  }
20
- export interface SliderProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "defaultValue" | "onChange" | "min" | "max" | "step" | "children"> {
21
+ export interface SliderProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "defaultValue" | "onChange" | "min" | "max" | "step" | "children">, FieldHelpProps {
21
22
  /** The value. On a log scale this is the real value, not the track position. */
22
23
  value: number;
23
24
  /** The new value — a number, not the DOM event, like `ToggleGroup`. Fired on every
@@ -82,4 +83,4 @@ export interface SliderProps extends Omit<InputHTMLAttributes<HTMLInputElement>,
82
83
  * Every other prop reaches the `<input>` — `name`, `id`, `aria-*`, `data-*`, `onBlur`,
83
84
  * `onPointerUp` for commit-on-release — and `disabled` also fades the label line.
84
85
  */
85
- export declare function Slider({ ref, value, onChange, min, max, step, scale, zeroStop, formatValue, label, hint, readout, marks, className, inputClassName, id, disabled, style, ...rest }: SliderProps): import("react").JSX.Element;
86
+ export declare function Slider({ ref, value, onChange, min, max, step, scale, zeroStop, formatValue, label, hint, help, readout, marks, className, inputClassName, id, disabled, style, ...rest }: SliderProps): import("react").JSX.Element;
@@ -49,6 +49,7 @@ function Slider({
49
49
  formatValue,
50
50
  label,
51
51
  hint,
52
+ help,
52
53
  readout,
53
54
  marks,
54
55
  className,
@@ -76,7 +77,12 @@ function Slider({
76
77
  };
77
78
  const normalizedMarks = (marks ?? []).map((m) => typeof m === "number" ? { value: m } : m).filter((m) => m.value >= min && m.value <= max);
78
79
  const hasMarkLabels = normalizedMarks.some((m) => m.label !== void 0);
79
- const messages = useFieldMessages({ hint, describedBy: rest["aria-describedby"] });
80
+ const messages = useFieldMessages({
81
+ hint,
82
+ help,
83
+ name: label ?? rest["aria-label"],
84
+ describedBy: rest["aria-describedby"]
85
+ });
80
86
  const labelHint = messages.labelHint;
81
87
  const hasHeader = label !== void 0 || labelHint !== void 0 || readout !== void 0;
82
88
  const plainReadout = typeof readout === "string" || typeof readout === "number";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/slider.tsx"],"sourcesContent":["import { useId } from \"react\";\nimport type { CSSProperties, ChangeEvent, InputHTMLAttributes, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useFieldMessages } from \"./field-parts\";\n\n/**\n * A range control on a native `<input type=\"range\">`.\n *\n * Lenkbank wrote the first one (`shared/ui/slider-field.tsx`) and then the case for\n * moving it here (`docs/ui-kit-slider-proposal.md`). This is that proposal's \"stage 1\"\n * primitive — the track, the thumb, the scale and the accessibility — plus the label\n * line and tick marks its SpeedSlider needed. The four decisions it argues should be\n * made once are made here, and each carries its note below:\n *\n * 1. a logarithmic scale whose far-left stop means exactly zero ({@link SliderProps.zeroStop});\n * 2. `aria-valuetext`, so a log track does not announce its 0–1 position as the value;\n * 3. a 24px pointer target, which needs the track and thumb drawn separately;\n * 4. the number is the truth and the track a view of it: a value outside `min`–`max`\n * is kept and the thumb pins at the end — nothing here clamps what it was given.\n *\n * Controlled only, like `ToggleGroup` and `NumberInput`: there is no uncontrolled mode\n * to get wrong, and neither app wants one. The package does no number formatting and\n * holds no locale — `formatValue` is how the caller's `Intl` result gets in.\n *\n * Not here, deliberately (the proposal's list): two thumbs, vertical orientation, and\n * a number box. A `NumberInput` goes in the `readout` slot when a typed value is\n * wanted beside the track.\n */\n\n/** On a log scale with {@link SliderProps.zeroStop}, the share of the track that is\n * the \"off\" stop. The first position to its right is `min`. */\nconst ZERO_STOP = 0.02;\n\n/** Position resolution on a log track — 1000 stops, fine enough that a drag reads as\n * continuous. PageUp/PageDown still cross a tenth of the track at a time. */\nconst LOG_STEP = 0.001;\n\n/**\n * Where a value sits on a logarithmic track, 0–1.\n *\n * With `zeroStop`, zero and below is 0 and the decades between `min` and `max` are\n * mapped onto the rest; without it, `min` is the far left. A value past either end\n * pins there — that is the DISPLAY clamping, never the value's.\n */\nexport function toLogPosition(value: number, min: number, max: number, zeroStop = true): number {\n if (value <= 0) return 0;\n const share = Math.log(Math.max(value, min) / min) / Math.log(max / min);\n const clamped = Math.min(1, Math.max(0, share));\n return zeroStop ? ZERO_STOP + (1 - ZERO_STOP) * clamped : clamped;\n}\n\n/** The inverse of {@link toLogPosition}: the value at a track position. */\nexport function fromLogPosition(position: number, min: number, max: number, zeroStop = true): number {\n if (zeroStop) {\n if (position < ZERO_STOP) return 0;\n return min * Math.pow(max / min, (position - ZERO_STOP) / (1 - ZERO_STOP));\n }\n return min * Math.pow(max / min, position);\n}\n\n// The input is the pointer target, so its box is `h-6` (24px, WCAG 2.5.8) and the\n// track that LOOKS like a track is a 6px strip drawn inside it. Once the box is taller\n// than the track its background cannot be the track, and with `appearance-none`\n// nothing else draws one — measured in Chromium by Lenkbank, that is a bare thumb\n// floating on nothing, in the browser's blue. Hence the vendor pseudo-elements: the\n// two engines share no selector for either half, so each half is spelt twice.\n//\n// The fill up to the thumb: WebKit has no progress pseudo-element, so its track is a\n// two-stop gradient that breaks at `--slider-fill` (set inline from the position), and\n// the gradient runs the other way in a right-to-left document, where the native range\n// runs the other way too. Firefox draws the fill itself with `::-moz-range-progress`,\n// which follows the direction on its own.\n//\n// The thumb's `-mt-[0.3125rem]` centres a 1rem thumb on a 0.375rem track — half the\n// difference — in rem since 0.32 (§3.2): the thumb and track grow with the text, and the\n// 5px it was left the thumb low of the track at 125 % and further at 150 %. WebKit lays\n// the thumb out from the track's top edge, Firefox centres it, so only one carries the\n// offset.\n//\n// The focus ring goes on the THUMB, not the box: the box is the full width of the\n// row, and a ring round all of it says \"this row is focused\" rather than \"the handle\n// you are about to move is\". Offset in the surface colour, because the thumb is\n// --brand and a --brand ring touching it would be invisible. Its width is the kit's\n// focus frame (`--focus-ring-width`, §5) — spelt out per engine, since `FOCUS_RING`'s\n// classes cannot carry the pseudo-element.\n//\n// At Large the box is the 48 px touch target (§4) — the track stays centred in it, and\n// the marks row below is pulled up by as much (see `MARKS_LARGE`).\nconst TRACK = [\n \"block h-6 large:h-[48px] w-full cursor-pointer appearance-none bg-transparent focus:outline-none disabled:cursor-not-allowed\",\n // WebKit / Blink\n \"[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full\",\n \"[&::-webkit-slider-runnable-track]:bg-[linear-gradient(to_right,var(--brand)_var(--slider-fill),var(--border-strong)_var(--slider-fill))]\",\n \"rtl:[&::-webkit-slider-runnable-track]:bg-[linear-gradient(to_left,var(--brand)_var(--slider-fill),var(--border-strong)_var(--slider-fill))]\",\n \"[&::-webkit-slider-thumb]:-mt-[0.3125rem] [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none\",\n \"[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-brand [&::-webkit-slider-thumb]:shadow\",\n \"focus-visible:[&::-webkit-slider-thumb]:ring-focus focus-visible:[&::-webkit-slider-thumb]:ring-brand\",\n \"focus-visible:[&::-webkit-slider-thumb]:ring-offset-2 focus-visible:[&::-webkit-slider-thumb]:ring-offset-surface\",\n // Firefox\n \"[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-[var(--border-strong)]\",\n \"[&::-moz-range-progress]:h-1.5 [&::-moz-range-progress]:rounded-full [&::-moz-range-progress]:bg-brand\",\n \"[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0\",\n \"[&::-moz-range-thumb]:bg-brand [&::-moz-range-thumb]:shadow\",\n \"focus-visible:[&::-moz-range-thumb]:ring-focus focus-visible:[&::-moz-range-thumb]:ring-brand\",\n \"focus-visible:[&::-moz-range-thumb]:ring-offset-2 focus-visible:[&::-moz-range-thumb]:ring-offset-surface\",\n].join(\" \");\n\n// The marks row sits `-mt-1.5` under a 1.5rem box, tucked against the track. In the 48 px\n// box at Large the track's lower edge is `(48px + 0.375rem) / 2` down, so the row comes\n// up by `(48px − 0.375rem) / 2` less the same 0.1875rem gap: `24px − 0.375rem`.\nconst MARKS_LARGE = \"large:-mt-[calc(24px-0.375rem)]\";\n\n/** A tick under the track. A bare number is a tick with no words. */\nexport interface SliderMark {\n value: number;\n /** Shown under the tick — \"30 km/h\", \"measured\". Visual only: the value itself is\n * announced through `aria-valuetext`, so the marks row is hidden from assistive\n * technology rather than read out as a list of numbers after the slider. */\n label?: ReactNode;\n}\n\nexport interface SliderProps\n extends Omit<\n InputHTMLAttributes<HTMLInputElement>,\n \"type\" | \"value\" | \"defaultValue\" | \"onChange\" | \"min\" | \"max\" | \"step\" | \"children\"\n > {\n /** The value. On a log scale this is the real value, not the track position. */\n value: number;\n /** The new value — a number, not the DOM event, like `ToggleGroup`. Fired on every\n * movement; debounce at the call site if each one costs a request. */\n onChange: (value: number) => void;\n /** The track's ends. Not a validity range: a `value` outside it is kept and the\n * thumb pins at the end (see the module note). On a log scale `min` must be\n * above zero — zero is what {@link zeroStop} is for. */\n min: number;\n max: number;\n /** Step on a linear scale (the native default is 1). Ignored on a log scale, which\n * steps by track position — a fixed step is wrong at one end of every decade. */\n step?: number;\n /**\n * How the track maps to the value. `\"log\"` for quantities that run over decades — a\n * proportional gain of 0.5 and one of 200 are both ordinary, and on a linear track\n * everything under a tenth of the range is the first pixel. Falls back to linear if\n * `min` is not above zero or `max` not above `min`, where a logarithm has no answer.\n */\n scale?: \"linear\" | \"log\";\n /**\n * Log scale only: the far-left stop means exactly 0 (\"off\"), and the first step to\n * its right is `min`. A logarithm cannot hold zero, and zero is often the question —\n * should there be an integral term at all? Default `true`; turn it off for a scale\n * where zero is meaningless (a zoom factor, a playback rate).\n */\n zeroStop?: boolean;\n /**\n * What a screen reader says for the value — \"12.5 mm\", \"Off\". Sets\n * `aria-valuetext`. Without it a log track would announce its 0–1 POSITION (\"0.63\"\n * for a gain of 12.5), so on a log scale the raw value is announced instead; pass\n * this for anything with a unit or a locale.\n */\n formatValue?: (value: number) => string;\n /** The words above the track, as a `<label for>`. Without it, pass `aria-label`. */\n label?: ReactNode;\n /** Something that belongs to the label rather than the value — in practice a\n * `FieldHint` \"?\". Rendered beside the label, outside the `<label>` element, so it\n * never becomes part of the slider's accessible name.\n *\n * {@link Select}'s rule since 0.34 (DUP-2, refactor audit 2026-10): plain TEXT is a\n * caption under the track, on the slider's `aria-describedby`, and so is a FieldHint\n * at Large and on a touch screen, where a \"?\" cannot be hovered open (§4) — the\n * Kurvenschmiede controller panel's Kp/Ki/Kd facts were a lone \"?\" on a phone. A\n * `HelpPopover` stays beside the label at every size. */\n hint?: ReactNode;\n /** The value as the reader sees it, at the end of the label line — a formatted\n * number, or a `NumberInput` for typing one. Rendered as given. */\n readout?: ReactNode;\n /** Ticks under the track, at these values. Marks outside `min`–`max` are dropped. */\n marks?: ReadonlyArray<number | SliderMark>;\n /** Classes for the `<input>`. `className` styles the outer wrapper. */\n inputClassName?: string;\n /** The `<input type=\"range\">`. A React 19 ref prop since 0.34 (MOD-4, refactor audit\n * 2026-10); it was `forwardRef`. */\n ref?: Ref<HTMLInputElement>;\n}\n\n/**\n * A native range input with the kit's track and thumb, an optional label line\n * (`label`, `hint`, `readout`), tick `marks`, and a logarithmic `scale`.\n *\n * Every other prop reaches the `<input>` — `name`, `id`, `aria-*`, `data-*`, `onBlur`,\n * `onPointerUp` for commit-on-release — and `disabled` also fades the label line.\n */\nexport function Slider({\n ref,\n value,\n onChange,\n min,\n max,\n step,\n scale = \"linear\",\n zeroStop = true,\n formatValue,\n label,\n hint,\n readout,\n marks,\n className,\n inputClassName,\n id,\n disabled,\n style,\n ...rest\n}: SliderProps) {\n const generated = useId();\n const inputId = id ?? generated;\n const log = scale === \"log\" && min > 0 && max > min;\n\n // Everything the native element sees is a POSITION; everything the caller sees is a\n // value. On a linear track the two are the same number.\n const trackMin = log ? 0 : min;\n const trackMax = log ? 1 : max;\n const position = (v: number) => (log ? toLogPosition(v, min, max, zeroStop) : Math.min(max, Math.max(min, v)));\n const current = position(value);\n // 0–1 share of the track, for the fill and the marks. A zero-width range (min ===\n // max) has no share to speak of; call it empty rather than divide by zero.\n const share = (p: number) => (trackMax > trackMin ? (p - trackMin) / (trackMax - trackMin) : 0);\n const outOfRange = value < min || value > max;\n\n // `aria-valuetext` replaces the announced NUMBER and leaves the announced name\n // alone. The caller's words first (`formatValue`, then a hand-written\n // `aria-valuetext`); failing those, the real value wherever the\n // native one would lie — every log position, and a linear value the thumb is only\n // pinned at. `toPrecision(4)` keeps a log value from being read out to sixteen\n // digits; it is not formatting, and `formatValue` is how to get a locale.\n const valueText = formatValue\n ? formatValue(value)\n : (rest[\"aria-valuetext\"] ??\n (log || outOfRange ? String(Number(value.toPrecision(4))) : undefined));\n\n const handleChange = (e: ChangeEvent<HTMLInputElement>) => {\n const raw = Number(e.target.value);\n if (!log) return onChange(raw);\n // The gap between the zero stop and `min` has to be crossable by keyboard. An\n // arrow key moves one LOG_STEP, which from zero lands inside the gap — and a gap\n // that rounded to the nearer end would send it straight back to zero, trapping\n // a keyboard user at \"off\". So inside the gap, moving up from zero means `min`\n // and anything else means zero.\n if (zeroStop && raw > 0 && raw < ZERO_STOP) return onChange(value <= 0 ? min : 0);\n onChange(fromLogPosition(raw, min, max, zeroStop));\n };\n\n const normalizedMarks = (marks ?? [])\n .map((m) => (typeof m === \"number\" ? { value: m } : m))\n .filter((m) => m.value >= min && m.value <= max);\n const hasMarkLabels = normalizedMarks.some((m) => m.label !== undefined);\n\n // The label line's \"?\", or the caption under the track (DUP-2).\n const messages = useFieldMessages({ hint, describedBy: rest[\"aria-describedby\"] });\n const labelHint = messages.labelHint;\n const hasHeader = label !== undefined || labelHint !== undefined || readout !== undefined;\n const plainReadout = typeof readout === \"string\" || typeof readout === \"number\";\n\n return (\n <div className={cn(\"w-full\", disabled && \"opacity-60\", className)}>\n {hasHeader && (\n // `select-none` on the words is not a nicety (Lenkbank feedback #93): a\n // mousedown on text beside a range input starts Chrome's native text drag, the\n // cursor turns to no-drop and the thumb stops following the pointer for the\n // rest of the gesture. The readout is only made unselectable when it is plain\n // text — a NumberInput in that slot has to stay selectable to be editable.\n // `flex-wrap`: a readout wider than the room beside the label (a value plus a\n // link button, \"0 / 60 / 120 km/h · Alle Geschwindigkeiten\") used to print on\n // top of it at 390px (lenkbank L1). Each keeps its own width, so the readout\n // drops to a line of its own, still at the end, only when the two don't fit.\n <div className=\"flex flex-wrap items-center gap-x-2 gap-y-0.5\">\n {label !== undefined && (\n <label\n htmlFor={inputId}\n className=\"min-w-0 select-none text-xs font-medium text-secondary\"\n >\n {label}\n </label>\n )}\n {labelHint !== undefined && <span className=\"flex shrink-0 items-center\">{labelHint}</span>}\n {readout !== undefined && (\n <span\n className={cn(\n \"ms-auto max-w-full text-xs tabular-nums text-primary\",\n plainReadout && \"select-none\",\n )}\n >\n {readout}\n </span>\n )}\n </div>\n )}\n <input\n ref={ref}\n id={label !== undefined ? inputId : id}\n disabled={disabled}\n {...rest}\n type=\"range\"\n min={trackMin}\n max={trackMax}\n step={log ? LOG_STEP : step}\n value={current}\n aria-valuetext={valueText}\n aria-describedby={messages.describedBy}\n onChange={handleChange}\n // The fill's break point, merged under the caller's own style.\n style={{ ...style, \"--slider-fill\": `${share(current) * 100}%` } as CSSProperties}\n className={cn(TRACK, inputClassName)}\n />\n {normalizedMarks.length > 0 && (\n // `mx-2` insets the row by half a thumb on each side, which is exactly the\n // range the thumb's CENTRE travels — so a tick at 0% sits under the thumb at\n // `min`, not under the track's rounded end. Each tick is a zero-width column\n // centred on its point, which centres its label on it in either direction\n // without a `translate` that would need flipping for right-to-left.\n <div\n aria-hidden\n className={cn(\"relative mx-2 -mt-1.5\", MARKS_LARGE, hasMarkLabels ? \"h-5\" : \"h-1.5\")}\n >\n {normalizedMarks.map((m) => (\n <span\n key={m.value}\n className=\"absolute top-0 flex w-0 flex-col items-center\"\n style={{ insetInlineStart: `${share(position(m.value)) * 100}%` }}\n >\n <span className=\"h-1.5 w-px shrink-0 bg-[var(--border-strong)]\" />\n {m.label !== undefined && (\n <span className=\"mt-0.5 select-none whitespace-nowrap text-caption leading-tight text-muted\">\n {m.label}\n </span>\n )}\n </span>\n ))}\n </div>\n )}\n {messages.below}\n </div>\n );\n}\n"],"mappings":";AAiRQ,SAEI,KAFJ;AAjRR,SAAS,aAAa;AAEtB,SAAS,UAAU;AACnB,SAAS,wBAAwB;AA4BjC,MAAM,YAAY;AAIlB,MAAM,WAAW;AASV,SAAS,cAAc,OAAe,KAAa,KAAa,WAAW,MAAc;AAC9F,MAAI,SAAS,EAAG,QAAO;AACvB,QAAM,QAAQ,KAAK,IAAI,KAAK,IAAI,OAAO,GAAG,IAAI,GAAG,IAAI,KAAK,IAAI,MAAM,GAAG;AACvE,QAAM,UAAU,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,KAAK,CAAC;AAC9C,SAAO,WAAW,aAAa,IAAI,aAAa,UAAU;AAC5D;AAGO,SAAS,gBAAgB,UAAkB,KAAa,KAAa,WAAW,MAAc;AACnG,MAAI,UAAU;AACZ,QAAI,WAAW,UAAW,QAAO;AACjC,WAAO,MAAM,KAAK,IAAI,MAAM,MAAM,WAAW,cAAc,IAAI,UAAU;AAAA,EAC3E;AACA,SAAO,MAAM,KAAK,IAAI,MAAM,KAAK,QAAQ;AAC3C;AA8BA,MAAM,QAAQ;AAAA,EACZ;AAAA;AAAA,EAEA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EAEA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,EAAE,KAAK,GAAG;AAKV,MAAM,cAAc;AAiFb,SAAS,OAAO;AAAA,EACrB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,QAAQ;AAAA,EACR,WAAW;AAAA,EACX;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAgB;AACd,QAAM,YAAY,MAAM;AACxB,QAAM,UAAU,MAAM;AACtB,QAAM,MAAM,UAAU,SAAS,MAAM,KAAK,MAAM;AAIhD,QAAM,WAAW,MAAM,IAAI;AAC3B,QAAM,WAAW,MAAM,IAAI;AAC3B,QAAM,WAAW,CAAC,MAAe,MAAM,cAAc,GAAG,KAAK,KAAK,QAAQ,IAAI,KAAK,IAAI,KAAK,KAAK,IAAI,KAAK,CAAC,CAAC;AAC5G,QAAM,UAAU,SAAS,KAAK;AAG9B,QAAM,QAAQ,CAAC,MAAe,WAAW,YAAY,IAAI,aAAa,WAAW,YAAY;AAC7F,QAAM,aAAa,QAAQ,OAAO,QAAQ;AAQ1C,QAAM,YAAY,cACd,YAAY,KAAK,IAChB,KAAK,gBAAgB,MACrB,OAAO,aAAa,OAAO,OAAO,MAAM,YAAY,CAAC,CAAC,CAAC,IAAI;AAEhE,QAAM,eAAe,CAAC,MAAqC;AACzD,UAAM,MAAM,OAAO,EAAE,OAAO,KAAK;AACjC,QAAI,CAAC,IAAK,QAAO,SAAS,GAAG;AAM7B,QAAI,YAAY,MAAM,KAAK,MAAM,UAAW,QAAO,SAAS,SAAS,IAAI,MAAM,CAAC;AAChF,aAAS,gBAAgB,KAAK,KAAK,KAAK,QAAQ,CAAC;AAAA,EACnD;AAEA,QAAM,mBAAmB,SAAS,CAAC,GAChC,IAAI,CAAC,MAAO,OAAO,MAAM,WAAW,EAAE,OAAO,EAAE,IAAI,CAAE,EACrD,OAAO,CAAC,MAAM,EAAE,SAAS,OAAO,EAAE,SAAS,GAAG;AACjD,QAAM,gBAAgB,gBAAgB,KAAK,CAAC,MAAM,EAAE,UAAU,MAAS;AAGvE,QAAM,WAAW,iBAAiB,EAAE,MAAM,aAAa,KAAK,kBAAkB,EAAE,CAAC;AACjF,QAAM,YAAY,SAAS;AAC3B,QAAM,YAAY,UAAU,UAAa,cAAc,UAAa,YAAY;AAChF,QAAM,eAAe,OAAO,YAAY,YAAY,OAAO,YAAY;AAEvE,SACE,qBAAC,SAAI,WAAW,GAAG,UAAU,YAAY,cAAc,SAAS,GAC7D;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAUC,qBAAC,SAAI,WAAU,iDACZ;AAAA,gBAAU,UACT;AAAA,QAAC;AAAA;AAAA,UACC,SAAS;AAAA,UACT,WAAU;AAAA,UAET;AAAA;AAAA,MACH;AAAA,MAED,cAAc,UAAa,oBAAC,UAAK,WAAU,8BAA8B,qBAAU;AAAA,MACnF,YAAY,UACX;AAAA,QAAC;AAAA;AAAA,UACC,WAAW;AAAA,YACT;AAAA,YACA,gBAAgB;AAAA,UAClB;AAAA,UAEC;AAAA;AAAA,MACH;AAAA,OAEJ;AAAA,IAEF;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA,IAAI,UAAU,SAAY,UAAU;AAAA,QACpC;AAAA,QACC,GAAG;AAAA,QACJ,MAAK;AAAA,QACL,KAAK;AAAA,QACL,KAAK;AAAA,QACL,MAAM,MAAM,WAAW;AAAA,QACvB,OAAO;AAAA,QACP,kBAAgB;AAAA,QAChB,oBAAkB,SAAS;AAAA,QAC3B,UAAU;AAAA,QAEV,OAAO,EAAE,GAAG,OAAO,iBAAiB,GAAG,MAAM,OAAO,IAAI,GAAG,IAAI;AAAA,QAC/D,WAAW,GAAG,OAAO,cAAc;AAAA;AAAA,IACrC;AAAA,IACC,gBAAgB,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA,IAMxB;AAAA,MAAC;AAAA;AAAA,QACC,eAAW;AAAA,QACX,WAAW,GAAG,yBAAyB,aAAa,gBAAgB,QAAQ,OAAO;AAAA,QAElF,0BAAgB,IAAI,CAAC,MACpB;AAAA,UAAC;AAAA;AAAA,YAEC,WAAU;AAAA,YACV,OAAO,EAAE,kBAAkB,GAAG,MAAM,SAAS,EAAE,KAAK,CAAC,IAAI,GAAG,IAAI;AAAA,YAEhE;AAAA,kCAAC,UAAK,WAAU,iDAAgD;AAAA,cAC/D,EAAE,UAAU,UACX,oBAAC,UAAK,WAAU,8EACb,YAAE,OACL;AAAA;AAAA;AAAA,UARG,EAAE;AAAA,QAUT,CACD;AAAA;AAAA,IACH;AAAA,IAED,SAAS;AAAA,KACZ;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/components/slider.tsx"],"sourcesContent":["import { useId } from \"react\";\nimport type { CSSProperties, ChangeEvent, InputHTMLAttributes, ReactNode, Ref } from \"react\";\nimport { cn } from \"../lib/cn\";\nimport { useFieldMessages, type FieldHelpProps } from \"./field-parts\";\n\n/**\n * A range control on a native `<input type=\"range\">`.\n *\n * Lenkbank wrote the first one (`shared/ui/slider-field.tsx`) and then the case for\n * moving it here (`docs/ui-kit-slider-proposal.md`). This is that proposal's \"stage 1\"\n * primitive — the track, the thumb, the scale and the accessibility — plus the label\n * line and tick marks its SpeedSlider needed. The four decisions it argues should be\n * made once are made here, and each carries its note below:\n *\n * 1. a logarithmic scale whose far-left stop means exactly zero ({@link SliderProps.zeroStop});\n * 2. `aria-valuetext`, so a log track does not announce its 0–1 position as the value;\n * 3. a 24px pointer target, which needs the track and thumb drawn separately;\n * 4. the number is the truth and the track a view of it: a value outside `min`–`max`\n * is kept and the thumb pins at the end — nothing here clamps what it was given.\n *\n * Controlled only, like `ToggleGroup` and `NumberInput`: there is no uncontrolled mode\n * to get wrong, and neither app wants one. The package does no number formatting and\n * holds no locale — `formatValue` is how the caller's `Intl` result gets in.\n *\n * Not here, deliberately (the proposal's list): two thumbs, vertical orientation, and\n * a number box. A `NumberInput` goes in the `readout` slot when a typed value is\n * wanted beside the track.\n */\n\n/** On a log scale with {@link SliderProps.zeroStop}, the share of the track that is\n * the \"off\" stop. The first position to its right is `min`. */\nconst ZERO_STOP = 0.02;\n\n/** Position resolution on a log track — 1000 stops, fine enough that a drag reads as\n * continuous. PageUp/PageDown still cross a tenth of the track at a time. */\nconst LOG_STEP = 0.001;\n\n/**\n * Where a value sits on a logarithmic track, 0–1.\n *\n * With `zeroStop`, zero and below is 0 and the decades between `min` and `max` are\n * mapped onto the rest; without it, `min` is the far left. A value past either end\n * pins there — that is the DISPLAY clamping, never the value's.\n */\nexport function toLogPosition(value: number, min: number, max: number, zeroStop = true): number {\n if (value <= 0) return 0;\n const share = Math.log(Math.max(value, min) / min) / Math.log(max / min);\n const clamped = Math.min(1, Math.max(0, share));\n return zeroStop ? ZERO_STOP + (1 - ZERO_STOP) * clamped : clamped;\n}\n\n/** The inverse of {@link toLogPosition}: the value at a track position. */\nexport function fromLogPosition(position: number, min: number, max: number, zeroStop = true): number {\n if (zeroStop) {\n if (position < ZERO_STOP) return 0;\n return min * Math.pow(max / min, (position - ZERO_STOP) / (1 - ZERO_STOP));\n }\n return min * Math.pow(max / min, position);\n}\n\n// The input is the pointer target, so its box is `h-6` (24px, WCAG 2.5.8) and the\n// track that LOOKS like a track is a 6px strip drawn inside it. Once the box is taller\n// than the track its background cannot be the track, and with `appearance-none`\n// nothing else draws one — measured in Chromium by Lenkbank, that is a bare thumb\n// floating on nothing, in the browser's blue. Hence the vendor pseudo-elements: the\n// two engines share no selector for either half, so each half is spelt twice.\n//\n// The fill up to the thumb: WebKit has no progress pseudo-element, so its track is a\n// two-stop gradient that breaks at `--slider-fill` (set inline from the position), and\n// the gradient runs the other way in a right-to-left document, where the native range\n// runs the other way too. Firefox draws the fill itself with `::-moz-range-progress`,\n// which follows the direction on its own.\n//\n// The thumb's `-mt-[0.3125rem]` centres a 1rem thumb on a 0.375rem track — half the\n// difference — in rem since 0.32 (§3.2): the thumb and track grow with the text, and the\n// 5px it was left the thumb low of the track at 125 % and further at 150 %. WebKit lays\n// the thumb out from the track's top edge, Firefox centres it, so only one carries the\n// offset.\n//\n// The focus ring goes on the THUMB, not the box: the box is the full width of the\n// row, and a ring round all of it says \"this row is focused\" rather than \"the handle\n// you are about to move is\". Offset in the surface colour, because the thumb is\n// --brand and a --brand ring touching it would be invisible. Its width is the kit's\n// focus frame (`--focus-ring-width`, §5) — spelt out per engine, since `FOCUS_RING`'s\n// classes cannot carry the pseudo-element.\n//\n// At Large the box is the 48 px touch target (§4) — the track stays centred in it, and\n// the marks row below is pulled up by as much (see `MARKS_LARGE`).\nconst TRACK = [\n \"block h-6 large:h-[48px] w-full cursor-pointer appearance-none bg-transparent focus:outline-none disabled:cursor-not-allowed\",\n // WebKit / Blink\n \"[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-full\",\n \"[&::-webkit-slider-runnable-track]:bg-[linear-gradient(to_right,var(--brand)_var(--slider-fill),var(--border-strong)_var(--slider-fill))]\",\n \"rtl:[&::-webkit-slider-runnable-track]:bg-[linear-gradient(to_left,var(--brand)_var(--slider-fill),var(--border-strong)_var(--slider-fill))]\",\n \"[&::-webkit-slider-thumb]:-mt-[0.3125rem] [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none\",\n \"[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-brand [&::-webkit-slider-thumb]:shadow\",\n \"focus-visible:[&::-webkit-slider-thumb]:ring-focus focus-visible:[&::-webkit-slider-thumb]:ring-brand\",\n \"focus-visible:[&::-webkit-slider-thumb]:ring-offset-2 focus-visible:[&::-webkit-slider-thumb]:ring-offset-surface\",\n // Firefox\n \"[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-[var(--border-strong)]\",\n \"[&::-moz-range-progress]:h-1.5 [&::-moz-range-progress]:rounded-full [&::-moz-range-progress]:bg-brand\",\n \"[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0\",\n \"[&::-moz-range-thumb]:bg-brand [&::-moz-range-thumb]:shadow\",\n \"focus-visible:[&::-moz-range-thumb]:ring-focus focus-visible:[&::-moz-range-thumb]:ring-brand\",\n \"focus-visible:[&::-moz-range-thumb]:ring-offset-2 focus-visible:[&::-moz-range-thumb]:ring-offset-surface\",\n].join(\" \");\n\n// The marks row sits `-mt-1.5` under a 1.5rem box, tucked against the track. In the 48 px\n// box at Large the track's lower edge is `(48px + 0.375rem) / 2` down, so the row comes\n// up by `(48px − 0.375rem) / 2` less the same 0.1875rem gap: `24px − 0.375rem`.\nconst MARKS_LARGE = \"large:-mt-[calc(24px-0.375rem)]\";\n\n/** A tick under the track. A bare number is a tick with no words. */\nexport interface SliderMark {\n value: number;\n /** Shown under the tick — \"30 km/h\", \"measured\". Visual only: the value itself is\n * announced through `aria-valuetext`, so the marks row is hidden from assistive\n * technology rather than read out as a list of numbers after the slider. */\n label?: ReactNode;\n}\n\nexport interface SliderProps\n extends Omit<\n InputHTMLAttributes<HTMLInputElement>,\n \"type\" | \"value\" | \"defaultValue\" | \"onChange\" | \"min\" | \"max\" | \"step\" | \"children\"\n >,\n FieldHelpProps {\n /** The value. On a log scale this is the real value, not the track position. */\n value: number;\n /** The new value — a number, not the DOM event, like `ToggleGroup`. Fired on every\n * movement; debounce at the call site if each one costs a request. */\n onChange: (value: number) => void;\n /** The track's ends. Not a validity range: a `value` outside it is kept and the\n * thumb pins at the end (see the module note). On a log scale `min` must be\n * above zero — zero is what {@link zeroStop} is for. */\n min: number;\n max: number;\n /** Step on a linear scale (the native default is 1). Ignored on a log scale, which\n * steps by track position — a fixed step is wrong at one end of every decade. */\n step?: number;\n /**\n * How the track maps to the value. `\"log\"` for quantities that run over decades — a\n * proportional gain of 0.5 and one of 200 are both ordinary, and on a linear track\n * everything under a tenth of the range is the first pixel. Falls back to linear if\n * `min` is not above zero or `max` not above `min`, where a logarithm has no answer.\n */\n scale?: \"linear\" | \"log\";\n /**\n * Log scale only: the far-left stop means exactly 0 (\"off\"), and the first step to\n * its right is `min`. A logarithm cannot hold zero, and zero is often the question —\n * should there be an integral term at all? Default `true`; turn it off for a scale\n * where zero is meaningless (a zoom factor, a playback rate).\n */\n zeroStop?: boolean;\n /**\n * What a screen reader says for the value — \"12.5 mm\", \"Off\". Sets\n * `aria-valuetext`. Without it a log track would announce its 0–1 POSITION (\"0.63\"\n * for a gain of 12.5), so on a log scale the raw value is announced instead; pass\n * this for anything with a unit or a locale.\n */\n formatValue?: (value: number) => string;\n /** The words above the track, as a `<label for>`. Without it, pass `aria-label`. */\n label?: ReactNode;\n /** Something that belongs to the label rather than the value — in practice a\n * `FieldHint` \"?\". Rendered beside the label, outside the `<label>` element, so it\n * never becomes part of the slider's accessible name.\n *\n * {@link Select}'s rule since 0.34 (DUP-2, refactor audit 2026-10): plain TEXT is a\n * caption under the track, on the slider's `aria-describedby`, and so is a FieldHint\n * at Large and on a touch screen, where a \"?\" cannot be hovered open (§4) — the\n * Kurvenschmiede controller panel's Kp/Ki/Kd facts were a lone \"?\" on a phone. A\n * `HelpPopover` stays beside the label at every size. */\n hint?: ReactNode;\n /** The value as the reader sees it, at the end of the label line — a formatted\n * number, or a `NumberInput` for typing one. Rendered as given. */\n readout?: ReactNode;\n /** Ticks under the track, at these values. Marks outside `min`–`max` are dropped. */\n marks?: ReadonlyArray<number | SliderMark>;\n /** Classes for the `<input>`. `className` styles the outer wrapper. */\n inputClassName?: string;\n /** The `<input type=\"range\">`. A React 19 ref prop since 0.34 (MOD-4, refactor audit\n * 2026-10); it was `forwardRef`. */\n ref?: Ref<HTMLInputElement>;\n}\n\n/**\n * A native range input with the kit's track and thumb, an optional label line\n * (`label`, `hint`, `readout`), tick `marks`, and a logarithmic `scale`.\n *\n * Every other prop reaches the `<input>` — `name`, `id`, `aria-*`, `data-*`, `onBlur`,\n * `onPointerUp` for commit-on-release — and `disabled` also fades the label line.\n */\nexport function Slider({\n ref,\n value,\n onChange,\n min,\n max,\n step,\n scale = \"linear\",\n zeroStop = true,\n formatValue,\n label,\n hint,\n help,\n readout,\n marks,\n className,\n inputClassName,\n id,\n disabled,\n style,\n ...rest\n}: SliderProps) {\n const generated = useId();\n const inputId = id ?? generated;\n const log = scale === \"log\" && min > 0 && max > min;\n\n // Everything the native element sees is a POSITION; everything the caller sees is a\n // value. On a linear track the two are the same number.\n const trackMin = log ? 0 : min;\n const trackMax = log ? 1 : max;\n const position = (v: number) => (log ? toLogPosition(v, min, max, zeroStop) : Math.min(max, Math.max(min, v)));\n const current = position(value);\n // 0–1 share of the track, for the fill and the marks. A zero-width range (min ===\n // max) has no share to speak of; call it empty rather than divide by zero.\n const share = (p: number) => (trackMax > trackMin ? (p - trackMin) / (trackMax - trackMin) : 0);\n const outOfRange = value < min || value > max;\n\n // `aria-valuetext` replaces the announced NUMBER and leaves the announced name\n // alone. The caller's words first (`formatValue`, then a hand-written\n // `aria-valuetext`); failing those, the real value wherever the\n // native one would lie — every log position, and a linear value the thumb is only\n // pinned at. `toPrecision(4)` keeps a log value from being read out to sixteen\n // digits; it is not formatting, and `formatValue` is how to get a locale.\n const valueText = formatValue\n ? formatValue(value)\n : (rest[\"aria-valuetext\"] ??\n (log || outOfRange ? String(Number(value.toPrecision(4))) : undefined));\n\n const handleChange = (e: ChangeEvent<HTMLInputElement>) => {\n const raw = Number(e.target.value);\n if (!log) return onChange(raw);\n // The gap between the zero stop and `min` has to be crossable by keyboard. An\n // arrow key moves one LOG_STEP, which from zero lands inside the gap — and a gap\n // that rounded to the nearer end would send it straight back to zero, trapping\n // a keyboard user at \"off\". So inside the gap, moving up from zero means `min`\n // and anything else means zero.\n if (zeroStop && raw > 0 && raw < ZERO_STOP) return onChange(value <= 0 ? min : 0);\n onChange(fromLogPosition(raw, min, max, zeroStop));\n };\n\n const normalizedMarks = (marks ?? [])\n .map((m) => (typeof m === \"number\" ? { value: m } : m))\n .filter((m) => m.value >= min && m.value <= max);\n const hasMarkLabels = normalizedMarks.some((m) => m.label !== undefined);\n\n // The label line's \"?\", or the caption under the track (DUP-2).\n const messages = useFieldMessages({\n hint,\n help,\n name: label ?? rest[\"aria-label\"],\n describedBy: rest[\"aria-describedby\"],\n });\n const labelHint = messages.labelHint;\n const hasHeader = label !== undefined || labelHint !== undefined || readout !== undefined;\n const plainReadout = typeof readout === \"string\" || typeof readout === \"number\";\n\n return (\n <div className={cn(\"w-full\", disabled && \"opacity-60\", className)}>\n {hasHeader && (\n // `select-none` on the words is not a nicety (Lenkbank feedback #93): a\n // mousedown on text beside a range input starts Chrome's native text drag, the\n // cursor turns to no-drop and the thumb stops following the pointer for the\n // rest of the gesture. The readout is only made unselectable when it is plain\n // text — a NumberInput in that slot has to stay selectable to be editable.\n // `flex-wrap`: a readout wider than the room beside the label (a value plus a\n // link button, \"0 / 60 / 120 km/h · Alle Geschwindigkeiten\") used to print on\n // top of it at 390px (lenkbank L1). Each keeps its own width, so the readout\n // drops to a line of its own, still at the end, only when the two don't fit.\n <div className=\"flex flex-wrap items-center gap-x-2 gap-y-0.5\">\n {label !== undefined && (\n <label\n htmlFor={inputId}\n className=\"min-w-0 select-none text-xs font-medium text-secondary\"\n >\n {label}\n </label>\n )}\n {labelHint !== undefined && <span className=\"flex shrink-0 items-center\">{labelHint}</span>}\n {readout !== undefined && (\n <span\n className={cn(\n \"ms-auto max-w-full text-xs tabular-nums text-primary\",\n plainReadout && \"select-none\",\n )}\n >\n {readout}\n </span>\n )}\n </div>\n )}\n <input\n ref={ref}\n id={label !== undefined ? inputId : id}\n disabled={disabled}\n {...rest}\n type=\"range\"\n min={trackMin}\n max={trackMax}\n step={log ? LOG_STEP : step}\n value={current}\n aria-valuetext={valueText}\n aria-describedby={messages.describedBy}\n onChange={handleChange}\n // The fill's break point, merged under the caller's own style.\n style={{ ...style, \"--slider-fill\": `${share(current) * 100}%` } as CSSProperties}\n className={cn(TRACK, inputClassName)}\n />\n {normalizedMarks.length > 0 && (\n // `mx-2` insets the row by half a thumb on each side, which is exactly the\n // range the thumb's CENTRE travels — so a tick at 0% sits under the thumb at\n // `min`, not under the track's rounded end. Each tick is a zero-width column\n // centred on its point, which centres its label on it in either direction\n // without a `translate` that would need flipping for right-to-left.\n <div\n aria-hidden\n className={cn(\"relative mx-2 -mt-1.5\", MARKS_LARGE, hasMarkLabels ? \"h-5\" : \"h-1.5\")}\n >\n {normalizedMarks.map((m) => (\n <span\n key={m.value}\n className=\"absolute top-0 flex w-0 flex-col items-center\"\n style={{ insetInlineStart: `${share(position(m.value)) * 100}%` }}\n >\n <span className=\"h-1.5 w-px shrink-0 bg-[var(--border-strong)]\" />\n {m.label !== undefined && (\n <span className=\"mt-0.5 select-none whitespace-nowrap text-caption leading-tight text-muted\">\n {m.label}\n </span>\n )}\n </span>\n ))}\n </div>\n )}\n {messages.below}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { createContext, isValidElement, useContext, useId, useLayoutEffect, useState } from "react";
3
+ import { createContext, useContext, useId, useLayoutEffect, useState } from "react";
4
4
  import { ArrowDown, ArrowUp, Minus } from "lucide-react";
5
5
  import { cn } from "../lib/cn.js";
6
+ import { plainText } from "../lib/plain-text.js";
6
7
  import { useKitLabels, useKitLink, useKitLocale } from "../i18n/kit-labels.js";
7
8
  import { pickLinkRenderer, RenderedKitLink } from "./text-link.js";
8
9
  import { Card } from "./card.js";
@@ -265,16 +266,6 @@ function FigureRow({
265
266
  }
266
267
  );
267
268
  }
268
- function plainText(node) {
269
- const walk = (n) => {
270
- if (typeof n === "string" || typeof n === "number") return String(n);
271
- if (Array.isArray(n)) return n.map(walk).join("");
272
- if (isValidElement(n)) return walk(n.props.children);
273
- return "";
274
- };
275
- const text = walk(node).replace(/\s+/g, " ").trim();
276
- return text === "" ? void 0 : text;
277
- }
278
269
  function useIsTruncated(el) {
279
270
  const [cut, setCut] = useState(false);
280
271
  useLayoutEffect(() => {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/components/stat-tile.tsx"],"sourcesContent":["import { createContext, isValidElement, useContext, useId, useLayoutEffect, useState } from \"react\";\nimport type { ComponentPropsWithRef, CSSProperties, ReactElement, ReactNode } from \"react\";\nimport { ArrowDown, ArrowUp, Minus } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels, useKitLink, useKitLocale, type LabelOverride } from \"../i18n/kit-labels\";\nimport { pickLinkRenderer, RenderedKitLink } from \"./text-link\";\nimport { Card } from \"./card\";\nimport { FieldHint } from \"./field-parts\";\nimport { useInlineFacts, useLargeText } from \"../hooks/use-large-text\";\nimport { Sparkline } from \"./sparkline\";\nimport { Tooltip } from \"./tooltip\";\n// The kit's one placeholder look, so a loading tile and the Skeleton list beside it\n// shimmer as one surface — and both stop under `prefers-reduced-motion`, which this\n// tile's private copy of the string did not.\nimport { SKELETON_CLASS } from \"./skeleton\";\nimport { DEFAULT_STAT_TILE_LABELS } from \"./stat-tile-labels\";\n\n/**\n * The words a stat tile speaks. The delta's arrow is an icon, and an icon is not a\n * sentence: a screen reader gets the direction (and, under `goodDirection`, the\n * verdict) as words, and a sighted reader who cannot tell the two delta colours apart\n * still has the arrow — neither judgement rides on colour alone.\n */\nexport interface StatTileLabels {\n /** The delta, rising. `amount` is already formatted and unsigned. */\n increase: (amount: string) => string;\n decrease: (amount: string) => string;\n unchanged: string;\n /** Wraps the direction sentence when `goodDirection` says the change is good. */\n better: (change: string) => string;\n worse: (change: string) => string;\n /** Spoken in place of a missing value (the tile shows a dash). */\n noValue: string;\n /** Spoken while `loading`. */\n loading: string;\n}\n\n// Defined in ./stat-tile-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_STAT_TILE_LABELS };\n\n/**\n * What colours a figure. Token-backed like everything else in the kit.\n *\n * - `income` / `expense` / `net` — the money trio (teal / amber / violet, CVD-safe).\n * - `signed` — a money figure read by its own sign: above zero `income`, below zero\n * `expense`, exactly zero neutral (a zero balance is neither news). Needs a NUMBER\n * value; a pre-formatted string cannot be read and stays neutral.\n * - `success` / `warning` / `danger` / `info` — a threshold verdict (\"phase margin\n * below 30°\"). Say it in the hint or description as well; colour alone is not one.\n */\nexport type StatTileTone =\n | \"neutral\"\n | \"brand\"\n | \"income\"\n | \"expense\"\n | \"net\"\n | \"signed\"\n | \"success\"\n | \"warning\"\n | \"danger\"\n | \"info\";\n\nconst TONE_CLASS: Record<Exclude<StatTileTone, \"signed\">, string> = {\n neutral: \"text-primary\",\n brand: \"text-brand\",\n income: \"text-money-income\",\n expense: \"text-money-expense\",\n net: \"text-money-net\",\n success: \"text-success\",\n warning: \"text-warning\",\n danger: \"text-danger\",\n info: \"text-info\",\n};\n\nfunction toneClass(tone: StatTileTone, value: unknown): string {\n if (tone !== \"signed\") return TONE_CLASS[tone];\n if (typeof value !== \"number\" || value === 0) return TONE_CLASS.neutral;\n return value < 0 ? TONE_CLASS.expense : TONE_CLASS.income;\n}\n\n/** A change since the previous period. A bare number is shorthand for `{ value }`. */\nexport interface StatTileDelta {\n /** Signed: above zero is \"up\". */\n value: number;\n /** `\"value\"` (default) formats it like the headline — same currency, same\n * notation. `\"percent\"` reads it as a RATIO: `0.12` is shown as 12 %. */\n unit?: \"value\" | \"percent\";\n /** What it is measured against, shown after it: \"vs last month\". */\n label?: ReactNode;\n}\n\n/** A smaller figure under the headline — a prior period, a projection, a split. */\nexport interface StatTileSubValue {\n label: ReactNode;\n /** A number is formatted like the headline; anything else is rendered as given. */\n value: ReactNode;\n /** Defaults to the tile's own `tone`, read per figure (so a `signed` tile shows a\n * teal September above an amber August). Sub-values are always the muted weight\n * of their tone: they are history, and must not outshout the figure above them.\n *\n * `\"none\"` leaves the figure untinted — the row's own muted text, like its label —\n * even on a toned tile: keksdose's report KpiCard (reports/charts/kpi-card.tsx),\n * whose `negative` convention tints only a figure below zero and leaves the rest\n * plain. (`\"neutral\"` is not that: it is the headline's full text colour, at the\n * sub-value's 75 %.) */\n tone?: StatTileTone | \"none\";\n}\n\n/** What {@link StatTileProps.renderLink} is handed. Spread it onto your router's\n * link: the class is what stretches the link over the whole tile. */\nexport interface StatTileLinkProps {\n href: string;\n className: string;\n \"aria-describedby\": string;\n children: ReactNode;\n}\n\n/**\n * A `div`'s props, `ref` included since 0.35 (round 0.35 R4, the follow-up to API-2): they\n * ride on to the {@link Card} that is the tile, so the ref is the tile's own `<div>` — the\n * one to scroll into view or measure. `ComponentPropsWithoutRef` only kept it out of the\n * type; at runtime `{...rest}` already carried it there.\n */\nexport interface StatTileProps\n extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"onClick\"> {\n /** What is measured. Also the tile's accessible name when it is a link/button. */\n label: ReactNode;\n /**\n * The headline. A number is formatted with `Intl.NumberFormat` in the kit's locale\n * (see `currency`, `compact`, `numberFormat`, `format`); a string or element is\n * rendered as given. `null`/`undefined` shows a dash and says \"No data\".\n */\n value?: ReactNode;\n /** ISO 4217 code — formats a number value (and a `\"value\"` delta, and numeric\n * sub-values) as money. */\n currency?: string;\n /** Compact notation: 12 400 → \"12K\". */\n compact?: boolean;\n /** Any further `Intl.NumberFormat` options, merged over the two above. */\n numberFormat?: Intl.NumberFormatOptions;\n /** Formats every NUMBER the tile shows, replacing the `Intl` default outright. */\n format?: (value: number) => string;\n locale?: string;\n /** A short unit after the value that `Intl` has no name for: \"ms\", \"dB\", \"Hz\". */\n unit?: ReactNode;\n /** The period the headline belongs to, shown to its left (\"SEP\"). */\n valueLabel?: ReactNode;\n tone?: StatTileTone;\n delta?: number | StatTileDelta;\n /** Which way is good news. Unset, the delta is stated and not judged (users up and\n * cost up are the same arrow); set, it is coloured and its sentence says so. */\n goodDirection?: \"up\" | \"down\";\n /** The explanation behind a \"?\" beside the label (a {@link FieldHint}) — hidden\n * until asked for, so it is for what a reader MIGHT want to know (\"excludes\n * transfers between your own accounts\"). For a line everyone should read, use\n * {@link StatTileProps.description} instead.\n *\n * 0.32 (docs/text-size-harmonization.md §4 \"No fact only in a tooltip\"): at Large\n * and Extra large, and on a touch screen, the words are shown under the label\n * instead of behind the \"?\". */\n hint?: string;\n /**\n * Keep the label to ONE line, cut with an ellipsis, and show the whole of it in a\n * {@link Tooltip} on hover and focus — but only while it is actually cut, so a\n * label that fits shows no bubble repeating itself. keksdose's admin MetricTile\n * wall: twenty tiles whose German labels (\"Aktive Abonnements (30 Tage)\") wrapped\n * onto two or three lines and pushed each tile's figure to a different height.\n *\n * The label element keeps the FULL text — the ellipsis is paint — so it is still\n * the tile's accessible name (and the stretched link's, on a link tile) exactly as\n * without this. Off by default: a wrapping label is what every existing tile does,\n * and a tile standing alone has room to.\n *\n * This is the tooltip: pass the label as a plain STRING rather than wrapping it in\n * your own `<Tooltip>` for the full text (keksdose's admin metrics-panel.tsx:134\n * does, from before this prop). A wrapped label shows a bubble even when nothing is\n * cut, and nests its trigger inside this one's.\n *\n * Ignored at Large and Extra large (0.32, §4): nothing truncates there, and the full\n * label would otherwise be a fact only in a tooltip.\n */\n truncateLabel?: boolean;\n /**\n * The tile's name as plain text, for the places that need a string rather than a\n * node — today the {@link StatTileProps.trend} sparkline's accessible name. Only\n * needed when `label` is not a string AND its text cannot be read from it: a label\n * element's text children are found on their own (a `<Tooltip label=\"…\">Spend\n * </Tooltip>` label still names its sparkline \"Spend\"), an icon or a formatted\n * message component's are not.\n */\n name?: string;\n /** A visible line under the value: \"Last 12 months\", \"3 estates / 9 buildings\" —\n * always shown, so it is for what every reader needs. An explanation only some\n * will want belongs in {@link StatTileProps.hint}, the \"?\" beside the label. */\n description?: ReactNode;\n /**\n * One figure that belongs WITH the headline rather than under it: keksdose's\n * \"Projected €1,850\" under a to-date \"€420\" (KpiCard, feedback #51), which it\n * currently smuggles into `description` with hand-set margins so the prior-month\n * sub-values do not come between the two. It is drawn directly under the value\n * (before the delta, the description and the sparkline) as a sub-value row, with\n * only a hair of space above it.\n *\n * A slot of its own rather than a placement switch for `subValues`: the history\n * rows (last month, last year) should stay at the bottom where they are, and moving\n * the whole list up to bring one row with it would put them between the headline\n * and its delta. Same shape as a sub-value, so the `tone` rules are the same.\n * Withheld while `loading`, like the sub-values.\n */\n projection?: StatTileSubValue;\n /**\n * Smaller figures under the headline, label left and figure right.\n *\n * At Large and Extra large (0.32, §4 \"StatTile shows its one big number; the sparkline\n * and sub-values move below\"): the tile leads with the headline and its delta, and the\n * projection, these rows and the sparkline follow beneath the description, each row's\n * label over its figure — a 2-up tile at 150 % is 120 px of text wide, and a label\n * beside a figure there squeezed both.\n */\n subValues?: StatTileSubValue[];\n /** Shorthand for a fluid {@link Sparkline} named after the tile and speaking in\n * the tile's number format. The name is the label's text; see\n * {@link StatTileProps.name} for a label that has none. */\n trend?: ReadonlyArray<number | null | undefined>;\n /** A custom sparkline (or any small graphic) in the same place. Wins over `trend`. */\n sparkline?: ReactNode;\n /** A decorative icon at the top end of the tile. */\n icon?: ReactNode;\n /** Anything below the rest — a progress bar, a link row. */\n footer?: ReactNode;\n /** Tag every figure `data-private`, so the host's demo-mode rule blurs it — the\n * same attribute `Tooltip redact` and the chart tooltip's value cell carry. */\n sensitive?: boolean;\n /** Skeleton in place of the value; delta, sub-values and trend are withheld. */\n loading?: boolean;\n /** `\"sm\"` for dense grids (a metrics wall); `\"md\"` the default KPI. */\n size?: \"sm\" | \"md\";\n /**\n * The tile's frame. `\"card\"` (default) is the bordered, shadowed card on the page.\n * `\"inset\"` is a tile INSIDE a card: {@link Card}'s `inset` — the raised\n * `--bg-surface-2`, a small radius, no border or shadow, since the card round it\n * already has them. `\"plain\"` drops the frame altogether — no fill and no padding —\n * for figures set straight into a card's content (a summary row under a card title),\n * where any box would be a box in a box. keksdose wrote both by hand over the card\n * tile (`border-0 shadow-none bg-transparent p-0`) for its tiles inside a card.\n */\n variant?: \"card\" | \"inset\" | \"plain\";\n /** Makes the tile a link. See {@link renderLink} for a router. */\n href?: string;\n /** Renders the link for `href` — pass your router's `<Link>` here, since a plain\n * `<a>` reloads a single-page app. Default: the `<UiKitProvider linkComponent>`\n * (for an in-app `href`), then `<a>`. */\n renderLink?: (props: StatTileLinkProps) => ReactElement;\n /** Makes the tile a button (ignored when `href` is given). */\n onClick?: () => void;\n labels?: LabelOverride<StatTileLabels>;\n}\n\n// One formatter per option set: a metrics wall is twenty of these.\nconst formatters = new Map<string, Intl.NumberFormat>();\nfunction numberFormatter(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat {\n const key = `${locale ?? \"\"}|${JSON.stringify(options)}`;\n let f = formatters.get(key);\n if (!f) {\n f = new Intl.NumberFormat(locale, options);\n formatters.set(key, f);\n }\n return f;\n}\n\n// The headline sizes are keyed on the TILE's width (it is a @container), not the\n// viewport: a grid beside a sidebar makes tiles narrowest exactly where a viewport\n// breakpoint would hand them the larger font. `sm` is keksdose's compact headline,\n// sized so a worst-case \"CHF 123,456.78\" still fits a 320px phone's 2-up tiles.\nconst VALUE_TEXT = {\n md: \"text-xl @[13rem]:text-2xl\",\n sm: \"text-xs @[8.5rem]:text-sm @[13rem]:text-base\",\n} as const;\nconst PADDING = { md: \"p-3 sm:p-4\", sm: \"px-2.5 py-2\" } as const;\n\n/**\n * The KPI card: a label, one number, and optionally how it moved, what it is made of\n * and which way it has been going.\n *\n * Replaces keksdose's `KpiCard` and admin `Tile`, lenkbank's metrics tiles and\n * kastlan's `KpiCard` — the same card four times, with four answers to the same\n * questions (how a currency is formatted, whether a delta is judged, what happens to\n * the figures in demo mode).\n *\n * Interactive tiles use a STRETCHED link: the label is the `<a>`/`<button>`, and its\n * `::after` covers the card. Making the card itself the link would nest the hint's\n * `<button>` inside it, which is invalid HTML and an unreachable control; this way\n * the hint sits above the overlay and stays its own tab stop. The link's name is the\n * label, and it is described by the value and the delta.\n */\nexport function StatTile({\n label,\n value,\n currency,\n compact = false,\n numberFormat,\n format,\n locale,\n unit,\n valueLabel,\n tone = \"neutral\",\n delta,\n goodDirection,\n hint,\n truncateLabel = false,\n name,\n description,\n projection,\n subValues,\n trend,\n sparkline,\n icon,\n footer,\n sensitive = false,\n loading = false,\n size = \"md\",\n variant = \"card\",\n href,\n renderLink,\n onClick,\n labels,\n className,\n ...rest\n}: StatTileProps) {\n const text = useKitLabels(\"statTile\", DEFAULT_STAT_TILE_LABELS, labels);\n // §4: at Large the one big number leads and the rest follows below it; the label\n // never truncates; on touch or at Large the hint is words, not a \"?\".\n const large = useLargeText();\n const inlineHint = useInlineFacts() && !!hint;\n if (large) truncateLabel = false;\n // A loading StatTileGrid puts every tile in it into the loading state and says\n // \"Loading…\" ONCE for all of them; the tile then keeps its own sentence to itself\n // (see StatTileGrid's `loading`).\n const gridLoading = useContext(StatTileGridLoadingContext);\n loading = loading || gridLoading;\n const kitLink = useKitLink();\n const [labelEl, setLabelEl] = useState<HTMLElement | null>(null);\n const labelCut = useIsTruncated(truncateLabel ? labelEl : null);\n const kitLocale = useKitLocale(locale);\n const id = useId();\n const valueId = `${id}-value`;\n const deltaId = `${id}-delta`;\n const priv = sensitive ? \"\" : undefined;\n\n const fmt =\n format ??\n ((n: number) =>\n numberFormatter(kitLocale, {\n ...(currency ? { style: \"currency\", currency } : null),\n ...(compact ? { notation: \"compact\" } : null),\n ...numberFormat,\n }).format(n));\n const show = (v: ReactNode) => (typeof v === \"number\" ? fmt(v) : v);\n\n const d = typeof delta === \"number\" ? { value: delta } : delta;\n const hasValue = value !== null && value !== undefined && value !== \"\";\n const labelText = name ?? plainText(label);\n\n // ── The label, which is also the link/button when the tile is interactive ──\n const stretched = cn(\n \"text-start uppercase tracking-wide\",\n \"after:absolute after:inset-0 after:rounded-lg after:content-['']\",\n // The kit's focus frame (0.32, §5), on the overlay — `FOCUS_RING`'s classes cannot\n // carry the `after:`, so its width is spelt out.\n \"focus-visible:outline-none focus-visible:after:ring-focus focus-visible:after:ring-inset focus-visible:after:ring-brand\",\n );\n const describedBy = d ? `${valueId} ${deltaId}` : valueId;\n let labelNode: ReactNode = label;\n if (href !== undefined) {\n const linkProps: StatTileLinkProps = {\n href,\n className: stretched,\n \"aria-describedby\": describedBy,\n children: label,\n };\n const kit = pickLinkRenderer<StatTileLinkProps>(undefined, kitLink, href);\n labelNode = renderLink ? (\n renderLink(linkProps)\n ) : kit ? (\n <RenderedKitLink render={kit} props={linkProps} />\n ) : (\n <a href={href} className={stretched} aria-describedby={describedBy}>\n {label}\n </a>\n );\n } else if (onClick) {\n labelNode = (\n <button type=\"button\" onClick={onClick} aria-describedby={describedBy} className={stretched}>\n {label}\n </button>\n );\n }\n const interactive = href !== undefined || onClick !== undefined;\n\n // ── Delta ──\n let deltaNode: ReactNode = null;\n if (d && !loading && Number.isFinite(d.value)) {\n const magnitude = Math.abs(d.value);\n const amount =\n d.unit === \"percent\"\n ? numberFormatter(kitLocale, { style: \"percent\", maximumFractionDigits: 1 }).format(magnitude)\n : fmt(magnitude);\n const dir = d.value > 0 ? \"up\" : d.value < 0 ? \"down\" : \"flat\";\n const Icon = dir === \"up\" ? ArrowUp : dir === \"down\" ? ArrowDown : Minus;\n let spoken = dir === \"up\" ? text.increase(amount) : dir === \"down\" ? text.decrease(amount) : text.unchanged;\n let deltaTone = \"text-muted\";\n if (goodDirection && dir !== \"flat\") {\n const good = dir === goodDirection;\n spoken = good ? text.better(spoken) : text.worse(spoken);\n deltaTone = good ? \"text-success\" : \"text-danger\";\n }\n deltaNode = (\n // `relative`: the containing block for the sr-only sentence (see the\n // sr-only-containment test for what an escaped one does to the page).\n <div className=\"relative mt-1 flex flex-wrap items-center gap-x-1.5 text-xs tabular-nums\">\n <span id={deltaId} data-private={priv} className={cn(\"inline-flex items-center gap-0.5 font-medium\", deltaTone)}>\n <Icon className=\"size-3 shrink-0\" aria-hidden />\n {/* The figure at zero too, like Delta (kastlan 48): \"0 %\" says the change was\n measured; a bare dash reads like a missing value. */}\n <span aria-hidden>{amount}</span>\n <span className=\"sr-only\">{spoken}</span>\n </span>\n {d.label != null && <span className=\"text-muted\">{d.label}</span>}\n </div>\n );\n }\n\n // ── Headline ──\n const valueClass = cn(\n \"whitespace-nowrap font-semibold tabular-nums\",\n VALUE_TEXT[size],\n toneClass(tone, value),\n );\n const headline = loading ? (\n <span id={valueId} className=\"relative block py-0.5\">\n <span aria-hidden className={cn(SKELETON_CLASS, \"block h-6 w-24 max-w-full\")} />\n {!gridLoading && <span className=\"sr-only\">{text.loading}</span>}\n </span>\n ) : hasValue ? (\n <span id={valueId} data-private={priv} className={cn(valueClass, valueLabel != null && !large && \"ms-auto\")}>\n {show(value)}\n {unit != null && (\n <span className=\"ms-1 text-[0.7em] font-medium text-muted\">{unit}</span>\n )}\n </span>\n ) : (\n <span id={valueId} className={cn(\"relative\", valueClass, \"text-muted\")}>\n <span aria-hidden>—</span>\n <span className=\"sr-only\">{text.noValue}</span>\n </span>\n );\n\n const graphic =\n loading ? null : sparkline ??\n (trend && (\n <Sparkline\n data={trend}\n fluid\n height={size === \"sm\" ? 20 : 28}\n label={labelText}\n formatValue={fmt}\n locale={kitLocale}\n tone={tone === \"signed\" ? \"signed\" : tone === \"neutral\" ? \"chart\" : tone}\n data-private={priv}\n />\n ));\n\n return (\n <Card\n {...rest}\n variant={variant === \"card\" ? \"default\" : \"inset\"}\n data-variant={variant === \"card\" ? undefined : variant}\n aria-busy={loading || undefined}\n className={cn(\n // @container: the headline sizes read the tile's width. relative: the stretched\n // link's overlay. overflow-hidden: at pathological widths a figure clips inside\n // the border instead of painting over the neighbouring tile.\n \"@container relative flex min-w-0 flex-col overflow-hidden\",\n // `plain` is the inset card with its fill and padding taken away — the figures\n // align with the content edge of the card they sit in. (Not `p-0` over the\n // padding: the padding's `sm:p-4` would still win from `sm` up.)\n variant === \"plain\" ? \"rounded-none bg-transparent p-0\" : PADDING[size],\n interactive && \"transition-colors hover:border-strong hover:bg-hover\",\n className,\n )}\n >\n <div className=\"flex items-start gap-2\">\n <div className=\"flex min-w-0 flex-1 items-center gap-1 break-words text-xs uppercase tracking-wide text-muted\">\n {truncateLabel && labelCut ? (\n // Portalled: the tile is `overflow-hidden`, and a bubble inside it would be\n // clipped by the very box whose edge cut the label.\n <Tooltip label={label} side=\"top\" portal className=\"flex min-w-0\">\n <span ref={setLabelEl} className=\"block min-w-0 truncate\">\n {labelNode}\n </span>\n </Tooltip>\n ) : (\n <span ref={setLabelEl} className={cn(\"min-w-0\", truncateLabel && \"block truncate\")}>\n {labelNode}\n </span>\n )}\n {/* Above the stretched link's overlay, so it stays hoverable and focusable. */}\n {hint && !inlineHint && <FieldHint label={hint} side=\"top\" className=\"relative z-10 shrink-0\" />}\n </div>\n {icon != null && (\n <span aria-hidden className=\"shrink-0 text-muted [&_svg]:size-4\">\n {icon}\n </span>\n )}\n </div>\n\n {inlineHint && (\n // §4: the hint as words under the label — the same muted type as `description`,\n // in the reading case of a sentence rather than the label's capitals.\n <p data-slot=\"stat-tile-hint\" className=\"mt-0.5 text-xs text-muted\">\n {hint}\n </p>\n )}\n\n {valueLabel != null ? (\n // Period-left / value-right, matching the sub-rows below it. flex-wrap lets a\n // too-long amount drop to its own end-aligned line instead of overflowing. At\n // Large the period goes over the figure, which then has the tile's width.\n <div className={cn(\"mt-1 flex items-baseline gap-x-1.5\", large ? \"flex-col items-start\" : \"flex-wrap\")}>\n <span className=\"shrink-0 text-xs uppercase tracking-wide text-muted\">{valueLabel}</span>\n {headline}\n </div>\n ) : (\n <div className=\"mt-1\">{headline}</div>\n )}\n\n {/* At Large the delta and description follow the figure at once, and the\n projection moves down with the other figures (see `subValues`). */}\n {!large && !loading && projection && (\n <dl className=\"mt-0.5\">\n <FigureRow figure={projection} tileTone={tone} show={show} priv={priv} />\n </dl>\n )}\n\n {deltaNode}\n {description != null && <div className=\"mt-1 text-xs text-muted\">{description}</div>}\n {!large && graphic && <div className=\"mt-2\">{graphic}</div>}\n\n {!loading && ((large && projection) || (subValues && subValues.length > 0)) && (\n <dl data-slot=\"stat-tile-figures\" className={cn(\"mt-1.5\", large ? \"space-y-1.5\" : \"space-y-0.5\")}>\n {large && projection && (\n <FigureRow figure={projection} tileTone={tone} show={show} priv={priv} stacked />\n )}\n {subValues?.map((sv, i) => (\n <FigureRow key={i} figure={sv} tileTone={tone} show={show} priv={priv} stacked={large} />\n ))}\n </dl>\n )}\n\n {large && graphic && (\n <div data-slot=\"stat-tile-graphic\" className=\"mt-2\">\n {graphic}\n </div>\n )}\n\n {footer != null && <div className=\"mt-2\">{footer}</div>}\n </Card>\n );\n}\n\n/** One label/value row of a `<dl>`: a sub-value, or the projection. */\nfunction FigureRow({\n figure,\n tileTone,\n show,\n priv,\n stacked = false,\n}: {\n figure: StatTileSubValue;\n tileTone: StatTileTone;\n show: (v: ReactNode) => ReactNode;\n priv: \"\" | undefined;\n /** The label over the figure (Large, §4) instead of beside it. */\n stacked?: boolean;\n}) {\n const own = figure.tone ?? (tileTone === \"neutral\" ? undefined : tileTone);\n const svTone = own === \"none\" ? undefined : own;\n // The LABEL gives way, never the figure. This row used to wrap, so on a narrow tile a\n // long label (\"AUG (SIGNED)\") dropped its amount onto a line of its own while the\n // next row stayed on one — two layouts in one list, and a figure no longer beside\n // the period it belongs to. Now the label breaks within itself and the amount stays\n // on the label's first line, end-aligned like every other row.\n return (\n <div\n className={cn(\n \"flex text-xs tabular-nums text-muted\",\n stacked ? \"flex-col items-start\" : \"items-baseline gap-x-2\",\n )}\n >\n <dt className=\"min-w-0 uppercase tracking-wide [overflow-wrap:anywhere]\">{figure.label}</dt>\n <dd\n data-private={priv}\n className={cn(\n \"shrink-0 whitespace-nowrap\",\n !stacked && \"ms-auto\",\n svTone && [toneClass(svTone, figure.value), \"opacity-75\"],\n )}\n >\n {show(figure.value)}\n </dd>\n </div>\n );\n}\n\n/**\n * The text of a label node, for the sparkline's name: a string or number as is, an\n * element by its children, recursively. A `<Tooltip label=\"…\">Spend</Tooltip>` label\n * yields \"Spend\" — the text the reader sees, not the bubble's. `undefined` when there\n * is no text to find (an icon), so the sparkline falls back to its unnamed summary.\n */\nfunction plainText(node: ReactNode): string | undefined {\n const walk = (n: ReactNode): string => {\n if (typeof n === \"string\" || typeof n === \"number\") return String(n);\n if (Array.isArray(n)) return n.map(walk).join(\"\");\n if (isValidElement<{ children?: ReactNode }>(n)) return walk(n.props.children);\n return \"\";\n };\n const text = walk(node).replace(/\\s+/g, \" \").trim();\n return text === \"\" ? undefined : text;\n}\n\n/**\n * Whether `el`'s text is wider than its box — i.e. whether `truncate` is cutting it.\n * Re-measured whenever the box resizes (a grid reflowing, a translation loading), not\n * only on mount. Keyed on the ELEMENT (a callback ref), because wrapping the label in\n * its tooltip remounts it, and an observer on the old node would watch nothing.\n */\nfunction useIsTruncated(el: HTMLElement | null): boolean {\n const [cut, setCut] = useState(false);\n useLayoutEffect(() => {\n // No reset while there is no node: between the unwrapped label unmounting and the\n // wrapped one mounting there is none, and a reset there would unwrap it again.\n if (!el) return;\n const measure = () => setCut(el.scrollWidth > el.clientWidth);\n measure();\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(measure);\n observer.observe(el);\n return () => observer.disconnect();\n }, [el]);\n return cut;\n}\n\nexport interface StatTileGridProps extends ComponentPropsWithRef<\"div\"> {\n /** The narrowest a tile may get before the grid drops a column. The default fits\n * two tiles on a 320px phone. */\n minTileWidth?: string;\n /**\n * Let a row that is not full stretch its tiles across the whole width. Default\n * `true` (CSS `auto-fit`): one or two tiles fill the row, as they always have.\n * `false` (`auto-fill`): the empty columns are kept, so every tile is the width of\n * a column whatever the count — a lone \"Balance\" tile in a card stays tile-sized\n * instead of becoming a banner the width of the card, and the tiles of a short row\n * line up with the columns of a full one above it.\n */\n stretch?: boolean;\n /**\n * Every tile in the grid is loading: each shows its skeleton (as with its own\n * `loading`), and the grid says \"Loading…\" ONCE, in a polite status region, instead\n * of each tile carrying its own sr-only \"Loading…\" — a wall of eight tiles was read\n * as eight \"Loading…\"s in a row. The grid is `aria-busy` meanwhile. A tile's own\n * `loading` inside a grid that is not loading still speaks for itself.\n */\n loading?: boolean;\n /** The grid's one sentence — `loading` from {@link StatTileLabels}. */\n labels?: LabelOverride<Pick<StatTileLabels, \"loading\">>;\n}\n\n/** Set by a loading {@link StatTileGrid}; read by each tile in it. */\nconst StatTileGridLoadingContext = createContext(false);\n\n/**\n * The row of tiles every dashboard in the three apps lays out by hand, each with its\n * own breakpoint ladder (`grid-cols-2 md:grid-cols-4 min-[2400px]:grid-cols-8` …).\n * Those ladders read the VIEWPORT and so ignored the sidebar: 6-up tiles at 155px in\n * the band where the sidebar was open. This fits as many columns as `minTileWidth`\n * allows in the space the grid actually has, and needs no breakpoint at all.\n */\nexport function StatTileGrid({\n minTileWidth = \"9rem\",\n stretch = true,\n loading = false,\n labels,\n className,\n style,\n children,\n ...rest\n}: StatTileGridProps) {\n const text = useKitLabels(\"statTile\", DEFAULT_STAT_TILE_LABELS, labels);\n const columns: CSSProperties = {\n gridTemplateColumns: `repeat(${stretch ? \"auto-fit\" : \"auto-fill\"}, minmax(min(100%, ${minTileWidth}), 1fr))`,\n };\n return (\n <div\n {...rest}\n aria-busy={loading || rest[\"aria-busy\"] || undefined}\n // `relative`: the containing block for the sr-only status, which is absolutely\n // positioned and so also takes no grid cell.\n className={cn(\"relative grid gap-3\", className)}\n style={{ ...columns, ...style }}\n >\n {/* Always mounted, filled while loading: a live region announces a CHANGE of\n its content, so one that appeared already holding the sentence might say\n nothing. */}\n <span role=\"status\" className=\"sr-only\">\n {loading ? text.loading : \"\"}\n </span>\n <StatTileGridLoadingContext.Provider value={loading}>{children}</StatTileGridLoadingContext.Provider>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/components/stat-tile.tsx"],"sourcesContent":["import { createContext, useContext, useId, useLayoutEffect, useState } from \"react\";\nimport type { ComponentPropsWithRef, CSSProperties, ReactElement, ReactNode } from \"react\";\nimport { ArrowDown, ArrowUp, Minus } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\n// The sparkline's name: the label's text (lib since 0.36, which a field's `help` \"?\" shares).\nimport { plainText } from \"../lib/plain-text\";\nimport { useKitLabels, useKitLink, useKitLocale, type LabelOverride } from \"../i18n/kit-labels\";\nimport { pickLinkRenderer, RenderedKitLink } from \"./text-link\";\nimport { Card } from \"./card\";\nimport { FieldHint } from \"./field-parts\";\nimport { useInlineFacts, useLargeText } from \"../hooks/use-large-text\";\nimport { Sparkline } from \"./sparkline\";\nimport { Tooltip } from \"./tooltip\";\n// The kit's one placeholder look, so a loading tile and the Skeleton list beside it\n// shimmer as one surface — and both stop under `prefers-reduced-motion`, which this\n// tile's private copy of the string did not.\nimport { SKELETON_CLASS } from \"./skeleton\";\nimport { DEFAULT_STAT_TILE_LABELS } from \"./stat-tile-labels\";\n\n/**\n * The words a stat tile speaks. The delta's arrow is an icon, and an icon is not a\n * sentence: a screen reader gets the direction (and, under `goodDirection`, the\n * verdict) as words, and a sighted reader who cannot tell the two delta colours apart\n * still has the arrow — neither judgement rides on colour alone.\n */\nexport interface StatTileLabels {\n /** The delta, rising. `amount` is already formatted and unsigned. */\n increase: (amount: string) => string;\n decrease: (amount: string) => string;\n unchanged: string;\n /** Wraps the direction sentence when `goodDirection` says the change is good. */\n better: (change: string) => string;\n worse: (change: string) => string;\n /** Spoken in place of a missing value (the tile shows a dash). */\n noValue: string;\n /** Spoken while `loading`. */\n loading: string;\n}\n\n// Defined in ./stat-tile-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_STAT_TILE_LABELS };\n\n/**\n * What colours a figure. Token-backed like everything else in the kit.\n *\n * - `income` / `expense` / `net` — the money trio (teal / amber / violet, CVD-safe).\n * - `signed` — a money figure read by its own sign: above zero `income`, below zero\n * `expense`, exactly zero neutral (a zero balance is neither news). Needs a NUMBER\n * value; a pre-formatted string cannot be read and stays neutral.\n * - `success` / `warning` / `danger` / `info` — a threshold verdict (\"phase margin\n * below 30°\"). Say it in the hint or description as well; colour alone is not one.\n */\nexport type StatTileTone =\n | \"neutral\"\n | \"brand\"\n | \"income\"\n | \"expense\"\n | \"net\"\n | \"signed\"\n | \"success\"\n | \"warning\"\n | \"danger\"\n | \"info\";\n\nconst TONE_CLASS: Record<Exclude<StatTileTone, \"signed\">, string> = {\n neutral: \"text-primary\",\n brand: \"text-brand\",\n income: \"text-money-income\",\n expense: \"text-money-expense\",\n net: \"text-money-net\",\n success: \"text-success\",\n warning: \"text-warning\",\n danger: \"text-danger\",\n info: \"text-info\",\n};\n\nfunction toneClass(tone: StatTileTone, value: unknown): string {\n if (tone !== \"signed\") return TONE_CLASS[tone];\n if (typeof value !== \"number\" || value === 0) return TONE_CLASS.neutral;\n return value < 0 ? TONE_CLASS.expense : TONE_CLASS.income;\n}\n\n/** A change since the previous period. A bare number is shorthand for `{ value }`. */\nexport interface StatTileDelta {\n /** Signed: above zero is \"up\". */\n value: number;\n /** `\"value\"` (default) formats it like the headline — same currency, same\n * notation. `\"percent\"` reads it as a RATIO: `0.12` is shown as 12 %. */\n unit?: \"value\" | \"percent\";\n /** What it is measured against, shown after it: \"vs last month\". */\n label?: ReactNode;\n}\n\n/** A smaller figure under the headline — a prior period, a projection, a split. */\nexport interface StatTileSubValue {\n label: ReactNode;\n /** A number is formatted like the headline; anything else is rendered as given. */\n value: ReactNode;\n /** Defaults to the tile's own `tone`, read per figure (so a `signed` tile shows a\n * teal September above an amber August). Sub-values are always the muted weight\n * of their tone: they are history, and must not outshout the figure above them.\n *\n * `\"none\"` leaves the figure untinted — the row's own muted text, like its label —\n * even on a toned tile: keksdose's report KpiCard (reports/charts/kpi-card.tsx),\n * whose `negative` convention tints only a figure below zero and leaves the rest\n * plain. (`\"neutral\"` is not that: it is the headline's full text colour, at the\n * sub-value's 75 %.) */\n tone?: StatTileTone | \"none\";\n}\n\n/** What {@link StatTileProps.renderLink} is handed. Spread it onto your router's\n * link: the class is what stretches the link over the whole tile. */\nexport interface StatTileLinkProps {\n href: string;\n className: string;\n \"aria-describedby\": string;\n children: ReactNode;\n}\n\n/**\n * A `div`'s props, `ref` included since 0.35 (round 0.35 R4, the follow-up to API-2): they\n * ride on to the {@link Card} that is the tile, so the ref is the tile's own `<div>` — the\n * one to scroll into view or measure. `ComponentPropsWithoutRef` only kept it out of the\n * type; at runtime `{...rest}` already carried it there.\n */\nexport interface StatTileProps\n extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"onClick\"> {\n /** What is measured. Also the tile's accessible name when it is a link/button. */\n label: ReactNode;\n /**\n * The headline. A number is formatted with `Intl.NumberFormat` in the kit's locale\n * (see `currency`, `compact`, `numberFormat`, `format`); a string or element is\n * rendered as given. `null`/`undefined` shows a dash and says \"No data\".\n */\n value?: ReactNode;\n /** ISO 4217 code — formats a number value (and a `\"value\"` delta, and numeric\n * sub-values) as money. */\n currency?: string;\n /** Compact notation: 12 400 → \"12K\". */\n compact?: boolean;\n /** Any further `Intl.NumberFormat` options, merged over the two above. */\n numberFormat?: Intl.NumberFormatOptions;\n /** Formats every NUMBER the tile shows, replacing the `Intl` default outright. */\n format?: (value: number) => string;\n locale?: string;\n /** A short unit after the value that `Intl` has no name for: \"ms\", \"dB\", \"Hz\". */\n unit?: ReactNode;\n /** The period the headline belongs to, shown to its left (\"SEP\"). */\n valueLabel?: ReactNode;\n tone?: StatTileTone;\n delta?: number | StatTileDelta;\n /** Which way is good news. Unset, the delta is stated and not judged (users up and\n * cost up are the same arrow); set, it is coloured and its sentence says so. */\n goodDirection?: \"up\" | \"down\";\n /** The explanation behind a \"?\" beside the label (a {@link FieldHint}) — hidden\n * until asked for, so it is for what a reader MIGHT want to know (\"excludes\n * transfers between your own accounts\"). For a line everyone should read, use\n * {@link StatTileProps.description} instead.\n *\n * 0.32 (docs/text-size-harmonization.md §4 \"No fact only in a tooltip\"): at Large\n * and Extra large, and on a touch screen, the words are shown under the label\n * instead of behind the \"?\". */\n hint?: string;\n /**\n * Keep the label to ONE line, cut with an ellipsis, and show the whole of it in a\n * {@link Tooltip} on hover and focus — but only while it is actually cut, so a\n * label that fits shows no bubble repeating itself. keksdose's admin MetricTile\n * wall: twenty tiles whose German labels (\"Aktive Abonnements (30 Tage)\") wrapped\n * onto two or three lines and pushed each tile's figure to a different height.\n *\n * The label element keeps the FULL text — the ellipsis is paint — so it is still\n * the tile's accessible name (and the stretched link's, on a link tile) exactly as\n * without this. Off by default: a wrapping label is what every existing tile does,\n * and a tile standing alone has room to.\n *\n * This is the tooltip: pass the label as a plain STRING rather than wrapping it in\n * your own `<Tooltip>` for the full text (keksdose's admin metrics-panel.tsx:134\n * does, from before this prop). A wrapped label shows a bubble even when nothing is\n * cut, and nests its trigger inside this one's.\n *\n * Ignored at Large and Extra large (0.32, §4): nothing truncates there, and the full\n * label would otherwise be a fact only in a tooltip.\n */\n truncateLabel?: boolean;\n /**\n * The tile's name as plain text, for the places that need a string rather than a\n * node — today the {@link StatTileProps.trend} sparkline's accessible name. Only\n * needed when `label` is not a string AND its text cannot be read from it: a label\n * element's text children are found on their own (a `<Tooltip label=\"…\">Spend\n * </Tooltip>` label still names its sparkline \"Spend\"), an icon or a formatted\n * message component's are not.\n */\n name?: string;\n /** A visible line under the value: \"Last 12 months\", \"3 estates / 9 buildings\" —\n * always shown, so it is for what every reader needs. An explanation only some\n * will want belongs in {@link StatTileProps.hint}, the \"?\" beside the label. */\n description?: ReactNode;\n /**\n * One figure that belongs WITH the headline rather than under it: keksdose's\n * \"Projected €1,850\" under a to-date \"€420\" (KpiCard, feedback #51), which it\n * currently smuggles into `description` with hand-set margins so the prior-month\n * sub-values do not come between the two. It is drawn directly under the value\n * (before the delta, the description and the sparkline) as a sub-value row, with\n * only a hair of space above it.\n *\n * A slot of its own rather than a placement switch for `subValues`: the history\n * rows (last month, last year) should stay at the bottom where they are, and moving\n * the whole list up to bring one row with it would put them between the headline\n * and its delta. Same shape as a sub-value, so the `tone` rules are the same.\n * Withheld while `loading`, like the sub-values.\n */\n projection?: StatTileSubValue;\n /**\n * Smaller figures under the headline, label left and figure right.\n *\n * At Large and Extra large (0.32, §4 \"StatTile shows its one big number; the sparkline\n * and sub-values move below\"): the tile leads with the headline and its delta, and the\n * projection, these rows and the sparkline follow beneath the description, each row's\n * label over its figure — a 2-up tile at 150 % is 120 px of text wide, and a label\n * beside a figure there squeezed both.\n */\n subValues?: StatTileSubValue[];\n /** Shorthand for a fluid {@link Sparkline} named after the tile and speaking in\n * the tile's number format. The name is the label's text; see\n * {@link StatTileProps.name} for a label that has none. */\n trend?: ReadonlyArray<number | null | undefined>;\n /** A custom sparkline (or any small graphic) in the same place. Wins over `trend`. */\n sparkline?: ReactNode;\n /** A decorative icon at the top end of the tile. */\n icon?: ReactNode;\n /** Anything below the rest — a progress bar, a link row. */\n footer?: ReactNode;\n /** Tag every figure `data-private`, so the host's demo-mode rule blurs it — the\n * same attribute `Tooltip redact` and the chart tooltip's value cell carry. */\n sensitive?: boolean;\n /** Skeleton in place of the value; delta, sub-values and trend are withheld. */\n loading?: boolean;\n /** `\"sm\"` for dense grids (a metrics wall); `\"md\"` the default KPI. */\n size?: \"sm\" | \"md\";\n /**\n * The tile's frame. `\"card\"` (default) is the bordered, shadowed card on the page.\n * `\"inset\"` is a tile INSIDE a card: {@link Card}'s `inset` — the raised\n * `--bg-surface-2`, a small radius, no border or shadow, since the card round it\n * already has them. `\"plain\"` drops the frame altogether — no fill and no padding —\n * for figures set straight into a card's content (a summary row under a card title),\n * where any box would be a box in a box. keksdose wrote both by hand over the card\n * tile (`border-0 shadow-none bg-transparent p-0`) for its tiles inside a card.\n */\n variant?: \"card\" | \"inset\" | \"plain\";\n /** Makes the tile a link. See {@link renderLink} for a router. */\n href?: string;\n /** Renders the link for `href` — pass your router's `<Link>` here, since a plain\n * `<a>` reloads a single-page app. Default: the `<UiKitProvider linkComponent>`\n * (for an in-app `href`), then `<a>`. */\n renderLink?: (props: StatTileLinkProps) => ReactElement;\n /** Makes the tile a button (ignored when `href` is given). */\n onClick?: () => void;\n labels?: LabelOverride<StatTileLabels>;\n}\n\n// One formatter per option set: a metrics wall is twenty of these.\nconst formatters = new Map<string, Intl.NumberFormat>();\nfunction numberFormatter(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat {\n const key = `${locale ?? \"\"}|${JSON.stringify(options)}`;\n let f = formatters.get(key);\n if (!f) {\n f = new Intl.NumberFormat(locale, options);\n formatters.set(key, f);\n }\n return f;\n}\n\n// The headline sizes are keyed on the TILE's width (it is a @container), not the\n// viewport: a grid beside a sidebar makes tiles narrowest exactly where a viewport\n// breakpoint would hand them the larger font. `sm` is keksdose's compact headline,\n// sized so a worst-case \"CHF 123,456.78\" still fits a 320px phone's 2-up tiles.\nconst VALUE_TEXT = {\n md: \"text-xl @[13rem]:text-2xl\",\n sm: \"text-xs @[8.5rem]:text-sm @[13rem]:text-base\",\n} as const;\nconst PADDING = { md: \"p-3 sm:p-4\", sm: \"px-2.5 py-2\" } as const;\n\n/**\n * The KPI card: a label, one number, and optionally how it moved, what it is made of\n * and which way it has been going.\n *\n * Replaces keksdose's `KpiCard` and admin `Tile`, lenkbank's metrics tiles and\n * kastlan's `KpiCard` — the same card four times, with four answers to the same\n * questions (how a currency is formatted, whether a delta is judged, what happens to\n * the figures in demo mode).\n *\n * Interactive tiles use a STRETCHED link: the label is the `<a>`/`<button>`, and its\n * `::after` covers the card. Making the card itself the link would nest the hint's\n * `<button>` inside it, which is invalid HTML and an unreachable control; this way\n * the hint sits above the overlay and stays its own tab stop. The link's name is the\n * label, and it is described by the value and the delta.\n */\nexport function StatTile({\n label,\n value,\n currency,\n compact = false,\n numberFormat,\n format,\n locale,\n unit,\n valueLabel,\n tone = \"neutral\",\n delta,\n goodDirection,\n hint,\n truncateLabel = false,\n name,\n description,\n projection,\n subValues,\n trend,\n sparkline,\n icon,\n footer,\n sensitive = false,\n loading = false,\n size = \"md\",\n variant = \"card\",\n href,\n renderLink,\n onClick,\n labels,\n className,\n ...rest\n}: StatTileProps) {\n const text = useKitLabels(\"statTile\", DEFAULT_STAT_TILE_LABELS, labels);\n // §4: at Large the one big number leads and the rest follows below it; the label\n // never truncates; on touch or at Large the hint is words, not a \"?\".\n const large = useLargeText();\n const inlineHint = useInlineFacts() && !!hint;\n if (large) truncateLabel = false;\n // A loading StatTileGrid puts every tile in it into the loading state and says\n // \"Loading…\" ONCE for all of them; the tile then keeps its own sentence to itself\n // (see StatTileGrid's `loading`).\n const gridLoading = useContext(StatTileGridLoadingContext);\n loading = loading || gridLoading;\n const kitLink = useKitLink();\n const [labelEl, setLabelEl] = useState<HTMLElement | null>(null);\n const labelCut = useIsTruncated(truncateLabel ? labelEl : null);\n const kitLocale = useKitLocale(locale);\n const id = useId();\n const valueId = `${id}-value`;\n const deltaId = `${id}-delta`;\n const priv = sensitive ? \"\" : undefined;\n\n const fmt =\n format ??\n ((n: number) =>\n numberFormatter(kitLocale, {\n ...(currency ? { style: \"currency\", currency } : null),\n ...(compact ? { notation: \"compact\" } : null),\n ...numberFormat,\n }).format(n));\n const show = (v: ReactNode) => (typeof v === \"number\" ? fmt(v) : v);\n\n const d = typeof delta === \"number\" ? { value: delta } : delta;\n const hasValue = value !== null && value !== undefined && value !== \"\";\n const labelText = name ?? plainText(label);\n\n // ── The label, which is also the link/button when the tile is interactive ──\n const stretched = cn(\n \"text-start uppercase tracking-wide\",\n \"after:absolute after:inset-0 after:rounded-lg after:content-['']\",\n // The kit's focus frame (0.32, §5), on the overlay — `FOCUS_RING`'s classes cannot\n // carry the `after:`, so its width is spelt out.\n \"focus-visible:outline-none focus-visible:after:ring-focus focus-visible:after:ring-inset focus-visible:after:ring-brand\",\n );\n const describedBy = d ? `${valueId} ${deltaId}` : valueId;\n let labelNode: ReactNode = label;\n if (href !== undefined) {\n const linkProps: StatTileLinkProps = {\n href,\n className: stretched,\n \"aria-describedby\": describedBy,\n children: label,\n };\n const kit = pickLinkRenderer<StatTileLinkProps>(undefined, kitLink, href);\n labelNode = renderLink ? (\n renderLink(linkProps)\n ) : kit ? (\n <RenderedKitLink render={kit} props={linkProps} />\n ) : (\n <a href={href} className={stretched} aria-describedby={describedBy}>\n {label}\n </a>\n );\n } else if (onClick) {\n labelNode = (\n <button type=\"button\" onClick={onClick} aria-describedby={describedBy} className={stretched}>\n {label}\n </button>\n );\n }\n const interactive = href !== undefined || onClick !== undefined;\n\n // ── Delta ──\n let deltaNode: ReactNode = null;\n if (d && !loading && Number.isFinite(d.value)) {\n const magnitude = Math.abs(d.value);\n const amount =\n d.unit === \"percent\"\n ? numberFormatter(kitLocale, { style: \"percent\", maximumFractionDigits: 1 }).format(magnitude)\n : fmt(magnitude);\n const dir = d.value > 0 ? \"up\" : d.value < 0 ? \"down\" : \"flat\";\n const Icon = dir === \"up\" ? ArrowUp : dir === \"down\" ? ArrowDown : Minus;\n let spoken = dir === \"up\" ? text.increase(amount) : dir === \"down\" ? text.decrease(amount) : text.unchanged;\n let deltaTone = \"text-muted\";\n if (goodDirection && dir !== \"flat\") {\n const good = dir === goodDirection;\n spoken = good ? text.better(spoken) : text.worse(spoken);\n deltaTone = good ? \"text-success\" : \"text-danger\";\n }\n deltaNode = (\n // `relative`: the containing block for the sr-only sentence (see the\n // sr-only-containment test for what an escaped one does to the page).\n <div className=\"relative mt-1 flex flex-wrap items-center gap-x-1.5 text-xs tabular-nums\">\n <span id={deltaId} data-private={priv} className={cn(\"inline-flex items-center gap-0.5 font-medium\", deltaTone)}>\n <Icon className=\"size-3 shrink-0\" aria-hidden />\n {/* The figure at zero too, like Delta (kastlan 48): \"0 %\" says the change was\n measured; a bare dash reads like a missing value. */}\n <span aria-hidden>{amount}</span>\n <span className=\"sr-only\">{spoken}</span>\n </span>\n {d.label != null && <span className=\"text-muted\">{d.label}</span>}\n </div>\n );\n }\n\n // ── Headline ──\n const valueClass = cn(\n \"whitespace-nowrap font-semibold tabular-nums\",\n VALUE_TEXT[size],\n toneClass(tone, value),\n );\n const headline = loading ? (\n <span id={valueId} className=\"relative block py-0.5\">\n <span aria-hidden className={cn(SKELETON_CLASS, \"block h-6 w-24 max-w-full\")} />\n {!gridLoading && <span className=\"sr-only\">{text.loading}</span>}\n </span>\n ) : hasValue ? (\n <span id={valueId} data-private={priv} className={cn(valueClass, valueLabel != null && !large && \"ms-auto\")}>\n {show(value)}\n {unit != null && (\n <span className=\"ms-1 text-[0.7em] font-medium text-muted\">{unit}</span>\n )}\n </span>\n ) : (\n <span id={valueId} className={cn(\"relative\", valueClass, \"text-muted\")}>\n <span aria-hidden>—</span>\n <span className=\"sr-only\">{text.noValue}</span>\n </span>\n );\n\n const graphic =\n loading ? null : sparkline ??\n (trend && (\n <Sparkline\n data={trend}\n fluid\n height={size === \"sm\" ? 20 : 28}\n label={labelText}\n formatValue={fmt}\n locale={kitLocale}\n tone={tone === \"signed\" ? \"signed\" : tone === \"neutral\" ? \"chart\" : tone}\n data-private={priv}\n />\n ));\n\n return (\n <Card\n {...rest}\n variant={variant === \"card\" ? \"default\" : \"inset\"}\n data-variant={variant === \"card\" ? undefined : variant}\n aria-busy={loading || undefined}\n className={cn(\n // @container: the headline sizes read the tile's width. relative: the stretched\n // link's overlay. overflow-hidden: at pathological widths a figure clips inside\n // the border instead of painting over the neighbouring tile.\n \"@container relative flex min-w-0 flex-col overflow-hidden\",\n // `plain` is the inset card with its fill and padding taken away — the figures\n // align with the content edge of the card they sit in. (Not `p-0` over the\n // padding: the padding's `sm:p-4` would still win from `sm` up.)\n variant === \"plain\" ? \"rounded-none bg-transparent p-0\" : PADDING[size],\n interactive && \"transition-colors hover:border-strong hover:bg-hover\",\n className,\n )}\n >\n <div className=\"flex items-start gap-2\">\n <div className=\"flex min-w-0 flex-1 items-center gap-1 break-words text-xs uppercase tracking-wide text-muted\">\n {truncateLabel && labelCut ? (\n // Portalled: the tile is `overflow-hidden`, and a bubble inside it would be\n // clipped by the very box whose edge cut the label.\n <Tooltip label={label} side=\"top\" portal className=\"flex min-w-0\">\n <span ref={setLabelEl} className=\"block min-w-0 truncate\">\n {labelNode}\n </span>\n </Tooltip>\n ) : (\n <span ref={setLabelEl} className={cn(\"min-w-0\", truncateLabel && \"block truncate\")}>\n {labelNode}\n </span>\n )}\n {/* Above the stretched link's overlay, so it stays hoverable and focusable. */}\n {hint && !inlineHint && <FieldHint label={hint} side=\"top\" className=\"relative z-10 shrink-0\" />}\n </div>\n {icon != null && (\n <span aria-hidden className=\"shrink-0 text-muted [&_svg]:size-4\">\n {icon}\n </span>\n )}\n </div>\n\n {inlineHint && (\n // §4: the hint as words under the label — the same muted type as `description`,\n // in the reading case of a sentence rather than the label's capitals.\n <p data-slot=\"stat-tile-hint\" className=\"mt-0.5 text-xs text-muted\">\n {hint}\n </p>\n )}\n\n {valueLabel != null ? (\n // Period-left / value-right, matching the sub-rows below it. flex-wrap lets a\n // too-long amount drop to its own end-aligned line instead of overflowing. At\n // Large the period goes over the figure, which then has the tile's width.\n <div className={cn(\"mt-1 flex items-baseline gap-x-1.5\", large ? \"flex-col items-start\" : \"flex-wrap\")}>\n <span className=\"shrink-0 text-xs uppercase tracking-wide text-muted\">{valueLabel}</span>\n {headline}\n </div>\n ) : (\n <div className=\"mt-1\">{headline}</div>\n )}\n\n {/* At Large the delta and description follow the figure at once, and the\n projection moves down with the other figures (see `subValues`). */}\n {!large && !loading && projection && (\n <dl className=\"mt-0.5\">\n <FigureRow figure={projection} tileTone={tone} show={show} priv={priv} />\n </dl>\n )}\n\n {deltaNode}\n {description != null && <div className=\"mt-1 text-xs text-muted\">{description}</div>}\n {!large && graphic && <div className=\"mt-2\">{graphic}</div>}\n\n {!loading && ((large && projection) || (subValues && subValues.length > 0)) && (\n <dl data-slot=\"stat-tile-figures\" className={cn(\"mt-1.5\", large ? \"space-y-1.5\" : \"space-y-0.5\")}>\n {large && projection && (\n <FigureRow figure={projection} tileTone={tone} show={show} priv={priv} stacked />\n )}\n {subValues?.map((sv, i) => (\n <FigureRow key={i} figure={sv} tileTone={tone} show={show} priv={priv} stacked={large} />\n ))}\n </dl>\n )}\n\n {large && graphic && (\n <div data-slot=\"stat-tile-graphic\" className=\"mt-2\">\n {graphic}\n </div>\n )}\n\n {footer != null && <div className=\"mt-2\">{footer}</div>}\n </Card>\n );\n}\n\n/** One label/value row of a `<dl>`: a sub-value, or the projection. */\nfunction FigureRow({\n figure,\n tileTone,\n show,\n priv,\n stacked = false,\n}: {\n figure: StatTileSubValue;\n tileTone: StatTileTone;\n show: (v: ReactNode) => ReactNode;\n priv: \"\" | undefined;\n /** The label over the figure (Large, §4) instead of beside it. */\n stacked?: boolean;\n}) {\n const own = figure.tone ?? (tileTone === \"neutral\" ? undefined : tileTone);\n const svTone = own === \"none\" ? undefined : own;\n // The LABEL gives way, never the figure. This row used to wrap, so on a narrow tile a\n // long label (\"AUG (SIGNED)\") dropped its amount onto a line of its own while the\n // next row stayed on one — two layouts in one list, and a figure no longer beside\n // the period it belongs to. Now the label breaks within itself and the amount stays\n // on the label's first line, end-aligned like every other row.\n return (\n <div\n className={cn(\n \"flex text-xs tabular-nums text-muted\",\n stacked ? \"flex-col items-start\" : \"items-baseline gap-x-2\",\n )}\n >\n <dt className=\"min-w-0 uppercase tracking-wide [overflow-wrap:anywhere]\">{figure.label}</dt>\n <dd\n data-private={priv}\n className={cn(\n \"shrink-0 whitespace-nowrap\",\n !stacked && \"ms-auto\",\n svTone && [toneClass(svTone, figure.value), \"opacity-75\"],\n )}\n >\n {show(figure.value)}\n </dd>\n </div>\n );\n}\n\n/**\n * Whether `el`'s text is wider than its box — i.e. whether `truncate` is cutting it.\n * Re-measured whenever the box resizes (a grid reflowing, a translation loading), not\n * only on mount. Keyed on the ELEMENT (a callback ref), because wrapping the label in\n * its tooltip remounts it, and an observer on the old node would watch nothing.\n */\nfunction useIsTruncated(el: HTMLElement | null): boolean {\n const [cut, setCut] = useState(false);\n useLayoutEffect(() => {\n // No reset while there is no node: between the unwrapped label unmounting and the\n // wrapped one mounting there is none, and a reset there would unwrap it again.\n if (!el) return;\n const measure = () => setCut(el.scrollWidth > el.clientWidth);\n measure();\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(measure);\n observer.observe(el);\n return () => observer.disconnect();\n }, [el]);\n return cut;\n}\n\nexport interface StatTileGridProps extends ComponentPropsWithRef<\"div\"> {\n /** The narrowest a tile may get before the grid drops a column. The default fits\n * two tiles on a 320px phone. */\n minTileWidth?: string;\n /**\n * Let a row that is not full stretch its tiles across the whole width. Default\n * `true` (CSS `auto-fit`): one or two tiles fill the row, as they always have.\n * `false` (`auto-fill`): the empty columns are kept, so every tile is the width of\n * a column whatever the count — a lone \"Balance\" tile in a card stays tile-sized\n * instead of becoming a banner the width of the card, and the tiles of a short row\n * line up with the columns of a full one above it.\n */\n stretch?: boolean;\n /**\n * Every tile in the grid is loading: each shows its skeleton (as with its own\n * `loading`), and the grid says \"Loading…\" ONCE, in a polite status region, instead\n * of each tile carrying its own sr-only \"Loading…\" — a wall of eight tiles was read\n * as eight \"Loading…\"s in a row. The grid is `aria-busy` meanwhile. A tile's own\n * `loading` inside a grid that is not loading still speaks for itself.\n */\n loading?: boolean;\n /** The grid's one sentence — `loading` from {@link StatTileLabels}. */\n labels?: LabelOverride<Pick<StatTileLabels, \"loading\">>;\n}\n\n/** Set by a loading {@link StatTileGrid}; read by each tile in it. */\nconst StatTileGridLoadingContext = createContext(false);\n\n/**\n * The row of tiles every dashboard in the three apps lays out by hand, each with its\n * own breakpoint ladder (`grid-cols-2 md:grid-cols-4 min-[2400px]:grid-cols-8` …).\n * Those ladders read the VIEWPORT and so ignored the sidebar: 6-up tiles at 155px in\n * the band where the sidebar was open. This fits as many columns as `minTileWidth`\n * allows in the space the grid actually has, and needs no breakpoint at all.\n */\nexport function StatTileGrid({\n minTileWidth = \"9rem\",\n stretch = true,\n loading = false,\n labels,\n className,\n style,\n children,\n ...rest\n}: StatTileGridProps) {\n const text = useKitLabels(\"statTile\", DEFAULT_STAT_TILE_LABELS, labels);\n const columns: CSSProperties = {\n gridTemplateColumns: `repeat(${stretch ? \"auto-fit\" : \"auto-fill\"}, minmax(min(100%, ${minTileWidth}), 1fr))`,\n };\n return (\n <div\n {...rest}\n aria-busy={loading || rest[\"aria-busy\"] || undefined}\n // `relative`: the containing block for the sr-only status, which is absolutely\n // positioned and so also takes no grid cell.\n className={cn(\"relative grid gap-3\", className)}\n style={{ ...columns, ...style }}\n >\n {/* Always mounted, filled while loading: a live region announces a CHANGE of\n its content, so one that appeared already holding the sentence might say\n nothing. */}\n <span role=\"status\" className=\"sr-only\">\n {loading ? text.loading : \"\"}\n </span>\n <StatTileGridLoadingContext.Provider value={loading}>{children}</StatTileGridLoadingContext.Provider>\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -2,6 +2,7 @@ import type { ComponentPropsWithRef, ReactNode } from "react";
2
2
  import { type LabelOverride } from "../i18n/kit-labels.js";
3
3
  import type { TileSize } from "./tile-radio.js";
4
4
  import { type CommitScope } from "./write-lock.js";
5
+ import { type FieldHelpProps } from "./field-parts.js";
5
6
  import { DEFAULT_SWATCH_PICKER_LABELS } from "./swatch-picker-labels.js";
6
7
  export interface SwatchPickerLabels {
7
8
  /** The "no colour" tile's name, when `allowNone` is set. */
@@ -50,7 +51,7 @@ export interface SwatchOption<T extends string> {
50
51
  * `onChange` is the picker's own — the chosen VALUE, not a DOM event — so the div's
51
52
  * is omitted rather than shadowed, as on {@link ToggleGroup}.
52
53
  */
53
- export interface SwatchPickerProps<T extends string> extends Omit<ComponentPropsWithRef<"div">, "onChange" | "children" | "defaultValue"> {
54
+ export interface SwatchPickerProps<T extends string> extends Omit<ComponentPropsWithRef<"div">, "onChange" | "children" | "defaultValue">, FieldHelpProps {
54
55
  options: SwatchOption<T>[];
55
56
  /** The chosen colour, or `null` for none. */
56
57
  value: T | null;
@@ -119,4 +120,4 @@ export interface SwatchPickerProps<T extends string> extends Omit<ComponentProps
119
120
  * choose as they go; Home and End jump to the ends. Left and right follow the page's
120
121
  * direction.
121
122
  */
122
- export declare function SwatchPicker<T extends string>({ options, value, onChange, allowNone, mixed, activation, size, tileClassName, disabled, labels, label, hint, disabledReason: ownDisabledReason, commit, className, ...rest }: SwatchPickerProps<T>): import("react").JSX.Element;
123
+ export declare function SwatchPicker<T extends string>({ options, value, onChange, allowNone, mixed, activation, size, tileClassName, disabled, labels, label, hint, help, disabledReason: ownDisabledReason, commit, className, ...rest }: SwatchPickerProps<T>): import("react").JSX.Element;
@@ -22,6 +22,7 @@ function SwatchPicker({
22
22
  labels,
23
23
  label,
24
24
  hint,
25
+ help,
25
26
  disabledReason: ownDisabledReason,
26
27
  commit,
27
28
  className,
@@ -36,6 +37,8 @@ function SwatchPicker({
36
37
  const labelled = hasContent(label);
37
38
  const messages = useFieldMessages({
38
39
  hint: labelled ? hint : void 0,
40
+ help: labelled ? help : void 0,
41
+ name: label,
39
42
  describedBy: rest["aria-describedby"],
40
43
  reasonId: mergeDescribedBy(mixed && mixedId, locked && reasonId)
41
44
  });