@eifi1/ui-kit 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (320) hide show
  1. package/README.md +42 -25
  2. package/dist/chart.d.ts +19 -14
  3. package/dist/components/account-chips.d.ts +10 -3
  4. package/dist/components/account-chips.js +5 -2
  5. package/dist/components/account-chips.js.map +1 -1
  6. package/dist/components/account-settings.d.ts +33 -2
  7. package/dist/components/account-settings.js +43 -21
  8. package/dist/components/account-settings.js.map +1 -1
  9. package/dist/components/amount-input.d.ts +53 -14
  10. package/dist/components/amount-input.js +17 -28
  11. package/dist/components/amount-input.js.map +1 -1
  12. package/dist/components/autocomplete.d.ts +29 -0
  13. package/dist/components/autocomplete.js +134 -104
  14. package/dist/components/autocomplete.js.map +1 -1
  15. package/dist/components/button-group.d.ts +19 -14
  16. package/dist/components/calculator.d.ts +19 -14
  17. package/dist/components/checkbox-group.d.ts +104 -0
  18. package/dist/components/checkbox-group.js +111 -0
  19. package/dist/components/checkbox-group.js.map +1 -0
  20. package/dist/components/checkbox.d.ts +19 -0
  21. package/dist/components/checkbox.js +30 -7
  22. package/dist/components/checkbox.js.map +1 -1
  23. package/dist/components/chip.d.ts +18 -0
  24. package/dist/components/chip.js +47 -10
  25. package/dist/components/chip.js.map +1 -1
  26. package/dist/components/column-mapper.d.ts +182 -0
  27. package/dist/components/column-mapper.js +375 -0
  28. package/dist/components/column-mapper.js.map +1 -0
  29. package/dist/components/combobox-core.d.ts +39 -2
  30. package/dist/components/combobox-core.js +29 -6
  31. package/dist/components/combobox-core.js.map +1 -1
  32. package/dist/components/combobox.d.ts +91 -2
  33. package/dist/components/combobox.js +454 -308
  34. package/dist/components/combobox.js.map +1 -1
  35. package/dist/components/confirm-dialog.d.ts +69 -161
  36. package/dist/components/confirm-dialog.js +7 -8
  37. package/dist/components/confirm-dialog.js.map +1 -1
  38. package/dist/components/copy-button.d.ts +16 -11
  39. package/dist/components/country-select.d.ts +167 -0
  40. package/dist/components/country-select.js +293 -0
  41. package/dist/components/country-select.js.map +1 -0
  42. package/dist/components/currency-select.d.ts +17 -1
  43. package/dist/components/currency-select.js +56 -23
  44. package/dist/components/currency-select.js.map +1 -1
  45. package/dist/components/danger-confirm.d.ts +69 -166
  46. package/dist/components/danger-confirm.js +144 -50
  47. package/dist/components/danger-confirm.js.map +1 -1
  48. package/dist/components/data-table-cells.d.ts +1 -2
  49. package/dist/components/data-table-filter-popover.d.ts +1 -2
  50. package/dist/components/data-table-filters.d.ts +1 -2
  51. package/dist/components/data-table-labels.d.ts +4 -97
  52. package/dist/components/data-table-labels.js +9 -1
  53. package/dist/components/data-table-labels.js.map +1 -1
  54. package/dist/components/data-table-pagination.d.ts +3 -1
  55. package/dist/components/data-table.d.ts +1 -2
  56. package/dist/components/data-table.js +98 -3
  57. package/dist/components/data-table.js.map +1 -1
  58. package/dist/components/date-picker.d.ts +66 -5
  59. package/dist/components/date-picker.js +378 -66
  60. package/dist/components/date-picker.js.map +1 -1
  61. package/dist/components/entity-combobox.d.ts +42 -1
  62. package/dist/components/entity-combobox.js +154 -113
  63. package/dist/components/entity-combobox.js.map +1 -1
  64. package/dist/components/facing-pair.d.ts +19 -14
  65. package/dist/components/field-parts.d.ts +224 -0
  66. package/dist/components/field-parts.js +156 -0
  67. package/dist/components/field-parts.js.map +1 -0
  68. package/dist/components/field-strip.d.ts +105 -0
  69. package/dist/components/field-strip.js +46 -0
  70. package/dist/components/field-strip.js.map +1 -0
  71. package/dist/components/file-button.d.ts +19 -14
  72. package/dist/components/file-button.js +13 -3
  73. package/dist/components/file-button.js.map +1 -1
  74. package/dist/components/file-dropzone.d.ts +41 -15
  75. package/dist/components/file-dropzone.js +57 -34
  76. package/dist/components/file-dropzone.js.map +1 -1
  77. package/dist/components/form-actions.d.ts +18 -13
  78. package/dist/components/form-actions.js +69 -5
  79. package/dist/components/form-actions.js.map +1 -1
  80. package/dist/components/iban-input.d.ts +69 -0
  81. package/dist/components/iban-input.js +125 -0
  82. package/dist/components/iban-input.js.map +1 -0
  83. package/dist/components/icon-picker.d.ts +21 -2
  84. package/dist/components/icon-picker.js +19 -4
  85. package/dist/components/icon-picker.js.map +1 -1
  86. package/dist/components/inline-edit-field.d.ts +167 -0
  87. package/dist/components/inline-edit-field.js +293 -0
  88. package/dist/components/inline-edit-field.js.map +1 -0
  89. package/dist/components/language-select.d.ts +130 -0
  90. package/dist/components/language-select.js +18 -0
  91. package/dist/components/language-select.js.map +1 -0
  92. package/dist/components/line-items.d.ts +25 -3
  93. package/dist/components/line-items.js +55 -46
  94. package/dist/components/line-items.js.map +1 -1
  95. package/dist/components/list.d.ts +25 -0
  96. package/dist/components/list.js +16 -5
  97. package/dist/components/list.js.map +1 -1
  98. package/dist/components/money-field.d.ts +32 -14
  99. package/dist/components/money-field.js.map +1 -1
  100. package/dist/components/month-picker.d.ts +52 -4
  101. package/dist/components/month-picker.js +291 -86
  102. package/dist/components/month-picker.js.map +1 -1
  103. package/dist/components/multi-entity-combobox.d.ts +26 -1
  104. package/dist/components/multi-entity-combobox.js +143 -102
  105. package/dist/components/multi-entity-combobox.js.map +1 -1
  106. package/dist/components/multi-select.d.ts +8 -1
  107. package/dist/components/multi-select.js +9 -4
  108. package/dist/components/multi-select.js.map +1 -1
  109. package/dist/components/number-field.d.ts +34 -19
  110. package/dist/components/number-field.js +6 -20
  111. package/dist/components/number-field.js.map +1 -1
  112. package/dist/components/number-input.d.ts +79 -19
  113. package/dist/components/number-input.js +90 -38
  114. package/dist/components/number-input.js.map +1 -1
  115. package/dist/components/numpad-sheet.d.ts +19 -14
  116. package/dist/components/one-time-code-input.d.ts +100 -0
  117. package/dist/components/one-time-code-input.js +137 -0
  118. package/dist/components/one-time-code-input.js.map +1 -0
  119. package/dist/components/phone-input.d.ts +69 -0
  120. package/dist/components/phone-input.js +171 -0
  121. package/dist/components/phone-input.js.map +1 -0
  122. package/dist/components/reauth-dialog.d.ts +2 -2
  123. package/dist/components/reauth-dialog.js +6 -8
  124. package/dist/components/reauth-dialog.js.map +1 -1
  125. package/dist/components/series-chart.d.ts +19 -14
  126. package/dist/components/settings-fields.d.ts +19 -14
  127. package/dist/components/share-card.d.ts +16 -11
  128. package/dist/components/sign-chip.d.ts +66 -0
  129. package/dist/components/sign-chip.js +39 -0
  130. package/dist/components/sign-chip.js.map +1 -0
  131. package/dist/components/swatch-picker.d.ts +43 -2
  132. package/dist/components/swatch-picker.js +80 -32
  133. package/dist/components/swatch-picker.js.map +1 -1
  134. package/dist/components/switch.d.ts +19 -0
  135. package/dist/components/switch.js +24 -6
  136. package/dist/components/switch.js.map +1 -1
  137. package/dist/components/text-link.d.ts +18 -13
  138. package/dist/components/tile-radio.d.ts +88 -13
  139. package/dist/components/tile-radio.js +28 -11
  140. package/dist/components/tile-radio.js.map +1 -1
  141. package/dist/components/time-input.d.ts +24 -15
  142. package/dist/components/time-input.js.map +1 -1
  143. package/dist/components/toggle-group.d.ts +47 -1
  144. package/dist/components/toggle-group.js +110 -69
  145. package/dist/components/toggle-group.js.map +1 -1
  146. package/dist/components/trigger-aria.d.ts +3 -0
  147. package/dist/components/trigger-aria.js +3 -1
  148. package/dist/components/trigger-aria.js.map +1 -1
  149. package/dist/components/ui.d.ts +16 -11
  150. package/dist/components/ui.js +284 -88
  151. package/dist/components/ui.js.map +1 -1
  152. package/dist/components/use-table-state.d.ts +1 -2
  153. package/dist/{data-table-3mi2R3so.d.ts → data-table-labels-B7OdnM0S.d.ts} +140 -3
  154. package/dist/data-table.d.ts +1 -2
  155. package/dist/data-table.js +2 -1
  156. package/dist/data-table.js.map +1 -1
  157. package/dist/feedback/feedback-attachment.d.ts +1 -1
  158. package/dist/feedback/feedback-attachment.js +348 -41
  159. package/dist/feedback/feedback-attachment.js.map +1 -1
  160. package/dist/feedback/feedback-dialog.d.ts +1 -1
  161. package/dist/feedback/feedback-dialog.js.map +1 -1
  162. package/dist/feedback/feedback-inbox.d.ts +3 -2
  163. package/dist/feedback/feedback-inbox.js.map +1 -1
  164. package/dist/feedback/feedback-thread.d.ts +68 -253
  165. package/dist/feedback/feedback-thread.js +46 -10
  166. package/dist/feedback/feedback-thread.js.map +1 -1
  167. package/dist/{kit-labels-D1J2_jyD.d.ts → feedback-BxeQVzwq.d.ts} +1206 -20
  168. package/dist/{feedback-attachment-WFiXJ8We.d.ts → feedback-attachment-fGAzZPf0.d.ts} +178 -11
  169. package/dist/feedback.d.ts +66 -2
  170. package/dist/hooks/use-file-drop.d.ts +19 -14
  171. package/dist/i18n/defaults.d.ts +19 -14
  172. package/dist/i18n/defaults.js +19 -2
  173. package/dist/i18n/defaults.js.map +1 -1
  174. package/dist/i18n/german.d.ts +19 -14
  175. package/dist/i18n/german.js +84 -5
  176. package/dist/i18n/german.js.map +1 -1
  177. package/dist/i18n/kit-labels.d.ts +15 -10
  178. package/dist/i18n/kit-labels.js +8 -2
  179. package/dist/i18n/kit-labels.js.map +1 -1
  180. package/dist/i18n/languages.d.ts +19 -14
  181. package/dist/i18n/locales/de-CH.d.ts +19 -14
  182. package/dist/i18n/locales/en.d.ts +19 -14
  183. package/dist/i18n/locales/en.js +16 -0
  184. package/dist/i18n/locales/en.js.map +1 -1
  185. package/dist/i18n/locales/es.d.ts +19 -14
  186. package/dist/i18n/locales/es.js +83 -5
  187. package/dist/i18n/locales/es.js.map +1 -1
  188. package/dist/i18n/locales/fr.d.ts +19 -14
  189. package/dist/i18n/locales/fr.js +82 -5
  190. package/dist/i18n/locales/fr.js.map +1 -1
  191. package/dist/i18n/locales/hu.d.ts +19 -14
  192. package/dist/i18n/locales/hu.js +85 -5
  193. package/dist/i18n/locales/hu.js.map +1 -1
  194. package/dist/i18n/locales/it.d.ts +19 -14
  195. package/dist/i18n/locales/it.js +84 -5
  196. package/dist/i18n/locales/it.js.map +1 -1
  197. package/dist/i18n/locales/zh.d.ts +19 -14
  198. package/dist/i18n/locales/zh.js +80 -5
  199. package/dist/i18n/locales/zh.js.map +1 -1
  200. package/dist/i18n/review.d.ts +19 -14
  201. package/dist/i18n/review.js +27 -1
  202. package/dist/i18n/review.js.map +1 -1
  203. package/dist/index.d.ts +21 -10
  204. package/dist/index.js +73 -2
  205. package/dist/index.js.map +1 -1
  206. package/dist/lib/column-mapping.d.ts +81 -0
  207. package/dist/lib/column-mapping.js +108 -0
  208. package/dist/lib/column-mapping.js.map +1 -0
  209. package/dist/lib/countries.d.ts +43 -0
  210. package/dist/lib/countries.js +39 -0
  211. package/dist/lib/countries.js.map +1 -0
  212. package/dist/lib/decimal-marks.d.ts +46 -0
  213. package/dist/lib/decimal-marks.js +40 -0
  214. package/dist/lib/decimal-marks.js.map +1 -0
  215. package/dist/lib/iban.d.ts +98 -0
  216. package/dist/lib/iban.js +154 -0
  217. package/dist/lib/iban.js.map +1 -0
  218. package/dist/lib/isin.d.ts +30 -0
  219. package/dist/lib/isin.js +36 -0
  220. package/dist/lib/isin.js.map +1 -0
  221. package/dist/lib/phone.d.ts +69 -0
  222. package/dist/lib/phone.js +121 -0
  223. package/dist/lib/phone.js.map +1 -0
  224. package/dist/lib/table-text.d.ts +109 -1
  225. package/dist/lib/table-text.js +122 -1
  226. package/dist/lib/table-text.js.map +1 -1
  227. package/dist/rhf/fields.d.ts +233 -18
  228. package/dist/rhf/fields.js +456 -3
  229. package/dist/rhf/fields.js.map +1 -1
  230. package/dist/rhf/form.d.ts +19 -14
  231. package/dist/rhf.d.ts +22 -15
  232. package/dist/rhf.js.map +1 -1
  233. package/dist/shell/app-shell.d.ts +18 -13
  234. package/dist/shell/top-bar-brand.d.ts +19 -14
  235. package/dist/shell/topbar-action-menu.d.ts +36 -3
  236. package/dist/shell/topbar-action-menu.js +74 -33
  237. package/dist/shell/topbar-action-menu.js.map +1 -1
  238. package/dist/shell.d.ts +18 -13
  239. package/dist/table-text.d.ts +1 -1
  240. package/dist/tour/tour.js +20 -24
  241. package/dist/tour/tour.js.map +1 -1
  242. package/dist/wizard/stepper-nav.d.ts +49 -16
  243. package/dist/wizard/stepper-nav.js +4 -0
  244. package/dist/wizard/stepper-nav.js.map +1 -1
  245. package/dist/wizard.d.ts +19 -14
  246. package/package.json +1 -1
  247. package/src/components/account-chips.tsx +17 -3
  248. package/src/components/account-settings.tsx +92 -26
  249. package/src/components/amount-input.tsx +68 -56
  250. package/src/components/autocomplete.tsx +198 -114
  251. package/src/components/checkbox-group.tsx +229 -0
  252. package/src/components/checkbox.tsx +72 -8
  253. package/src/components/chip.tsx +80 -10
  254. package/src/components/column-mapper.tsx +666 -0
  255. package/src/components/combobox-core.tsx +82 -9
  256. package/src/components/combobox.tsx +661 -340
  257. package/src/components/confirm-dialog.tsx +12 -8
  258. package/src/components/country-select.tsx +556 -0
  259. package/src/components/currency-select.tsx +109 -33
  260. package/src/components/danger-confirm.tsx +429 -82
  261. package/src/components/data-table-labels.ts +16 -1
  262. package/src/components/data-table.tsx +175 -6
  263. package/src/components/date-picker.tsx +595 -71
  264. package/src/components/entity-combobox.tsx +235 -124
  265. package/src/components/field-parts.tsx +403 -0
  266. package/src/components/field-strip.tsx +149 -0
  267. package/src/components/file-button.tsx +36 -3
  268. package/src/components/file-dropzone.tsx +106 -38
  269. package/src/components/form-actions.tsx +147 -6
  270. package/src/components/iban-input.tsx +231 -0
  271. package/src/components/icon-picker.tsx +47 -6
  272. package/src/components/inline-edit-field.tsx +533 -0
  273. package/src/components/language-select.tsx +78 -0
  274. package/src/components/line-items.tsx +35 -2
  275. package/src/components/list.tsx +55 -6
  276. package/src/components/money-field.tsx +13 -0
  277. package/src/components/month-picker.tsx +400 -63
  278. package/src/components/multi-entity-combobox.tsx +207 -120
  279. package/src/components/multi-select.tsx +80 -58
  280. package/src/components/number-field.tsx +28 -46
  281. package/src/components/number-input.tsx +210 -43
  282. package/src/components/one-time-code-input.tsx +288 -0
  283. package/src/components/phone-input.tsx +299 -0
  284. package/src/components/reauth-dialog.tsx +17 -18
  285. package/src/components/sign-chip.tsx +103 -0
  286. package/src/components/swatch-picker.tsx +143 -31
  287. package/src/components/switch.tsx +58 -7
  288. package/src/components/tile-radio.tsx +145 -29
  289. package/src/components/time-input.tsx +8 -1
  290. package/src/components/toggle-group.tsx +206 -73
  291. package/src/components/trigger-aria.ts +5 -0
  292. package/src/components/ui.tsx +479 -138
  293. package/src/data-table.ts +2 -1
  294. package/src/feedback/feedback-attachment.tsx +597 -46
  295. package/src/feedback/feedback-dialog.tsx +9 -3
  296. package/src/feedback/feedback-inbox.tsx +3 -2
  297. package/src/feedback/feedback-thread.tsx +113 -9
  298. package/src/i18n/defaults.ts +18 -1
  299. package/src/i18n/german.ts +89 -0
  300. package/src/i18n/kit-labels.tsx +82 -2
  301. package/src/i18n/locales/en.ts +36 -4
  302. package/src/i18n/locales/es.ts +87 -0
  303. package/src/i18n/locales/fr.ts +87 -0
  304. package/src/i18n/locales/hu.ts +83 -0
  305. package/src/i18n/locales/it.ts +88 -0
  306. package/src/i18n/locales/zh.ts +76 -0
  307. package/src/i18n/review.ts +26 -0
  308. package/src/index.ts +71 -3
  309. package/src/lib/column-mapping.ts +234 -0
  310. package/src/lib/countries.ts +91 -0
  311. package/src/lib/decimal-marks.ts +99 -0
  312. package/src/lib/iban.ts +171 -0
  313. package/src/lib/isin.ts +62 -0
  314. package/src/lib/phone.ts +211 -0
  315. package/src/lib/table-text.ts +271 -0
  316. package/src/rhf/fields.tsx +860 -9
  317. package/src/rhf.ts +3 -1
  318. package/src/shell/topbar-action-menu.tsx +134 -38
  319. package/src/tour/tour.tsx +66 -28
  320. package/src/wizard/stepper-nav.tsx +35 -1
@@ -3,6 +3,9 @@ import type { ComponentPropsWithoutRef, KeyboardEvent, ReactElement, ReactNode }
3
3
  import { cn } from "../lib/cn";
4
4
  import { horizontalStep } from "../lib/direction";
5
5
  import { FIELD_INVALID, FloatingField, Label } from "./ui";
6
+ import { Tooltip } from "./tooltip";
7
+ import { useCommitReason } from "./write-lock";
8
+ import { hasContent, LabelStrip } from "./field-parts";
6
9
 
7
10
  export interface ToggleOption<T extends string> {
8
11
  value: T;
@@ -59,6 +62,9 @@ export interface ToggleGroupBaseProps<T extends string>
59
62
  * `sm`: 12px options with `px-2 py-1` — the compact group keksdose's rule editor
60
63
  * (rule-editor:204) writes as `optionClassName="px-2 py-1 text-xs"` beside a small
61
64
  * caption. `md` (default) is the size every other group has.
65
+ *
66
+ * Honoured inside the field chrome and the strip too (keksdose K1): the 12px type
67
+ * stays, and the field is still 42px — see `CHROME_PAD`.
62
68
  */
63
69
  size?: "sm" | "md";
64
70
  /**
@@ -84,11 +90,32 @@ export interface ToggleGroupBaseProps<T extends string>
84
90
  * cap pair sits in a `Field` column between two labelled-above inputs, where the
85
91
  * chrome's inner label would be the only one of its kind).
86
92
  *
93
+ * `"strip"`: the 11px static label (and `hint`) in a strip OVER the bare group, for a
94
+ * group that stands in a row of fields but is not one (keksdose K2 — the transaction
95
+ * row's cleared-status picker, live #288 / #431 / dev#468). No field border: the group
96
+ * keeps its own box, and its segments drop their vertical padding so a 16px strip
97
+ * plus the 26px group is exactly a labelled {@link Input}'s 42px, the label on the
98
+ * same line as theirs. `error` and `caption` go under the group, as in the other two.
99
+ *
87
100
  * Inside a `Field`, pass no `label` at all and spread the render-prop instead —
88
101
  * `{(ids, { labelId }) => <ToggleGroup {...ids} aria-labelledby={labelId} … />}` —
89
102
  * so the Field's label names the group and its hint and error describe it.
90
103
  */
91
- labelPlacement?: "field" | "above";
104
+ labelPlacement?: "field" | "above" | "strip";
105
+ /**
106
+ * Classes for the field chrome's own box — the bordered, padded box round the
107
+ * segments — merged last, so they win. `className` styles the wrapper the chrome sits
108
+ * in; this reaches the box itself. Only with `label` in the `"field"` placement.
109
+ *
110
+ * For a field JOINED flush to a neighbour (keksdose K1): reports-filters.tsx joins its
111
+ * "group by" field to the range select on its start edge from `md` up, and squares the
112
+ * shared corners by hand chrome today — here that is
113
+ * `chromeClassName="md:rounded-s-none"` with `className="md:-ms-px"` (the wrapper
114
+ * overlaps the neighbour by the one border they share). A class rather than a
115
+ * `joined="start"` because every such join keksdose has is responsive: on a phone the
116
+ * two stack and both keep their corners.
117
+ */
118
+ chromeClassName?: string;
92
119
  /** A {@link FieldHint} on the label line, as on a labelled {@link Select}. Only with
93
120
  * `label`. */
94
121
  hint?: ReactNode;
@@ -96,8 +123,60 @@ export interface ToggleGroupBaseProps<T extends string>
96
123
  * `--danger`, marks the group `aria-invalid` and describes it with the message, as
97
124
  * {@link Select}'s `error` does. Only with `label`. */
98
125
  error?: ReactNode;
126
+ /**
127
+ * Why the group cannot be changed right now — {@link Button}'s `disabledReason`, for
128
+ * a group that saves itself on change (keksdose K3: the last `SaveGuard` sites are
129
+ * self-committing controls like this one, and SaveGuard's native `disabled` took
130
+ * them out of the tab order so the reason never reached a keyboard).
131
+ *
132
+ * The group stays reachable — its segments are `aria-disabled`, not `disabled`, and
133
+ * a radio group's arrow keys still walk them — but nothing reaches `onChange`, and
134
+ * the reason shows in the kit {@link Tooltip} over the segments on hover or focus and
135
+ * describes each segment. The chosen option keeps its fill, as under `disabled`.
136
+ * Wins over `disabled`, as on Button: passing both keeps the group reachable.
137
+ */
138
+ disabledReason?: ReactNode;
139
+ /**
140
+ * This group COMMITS — choosing an option saves it. Under a locked
141
+ * {@link WriteLockProvider} it takes the `disabledReason` path with the lock's
142
+ * reason (which wins over a `disabledReason` of its own): *"everything renders; only
143
+ * the commit is locked"* (keksdose dev#496/#497), and a control that is its own commit
144
+ * must render refused, never live-and-discarding. No provider, or an unlocked one:
145
+ * no effect.
146
+ */
147
+ commit?: boolean;
99
148
  }
100
149
 
150
+ /**
151
+ * The field chrome's vertical geometry — keksdose K1, the kit reintroducing feedback
152
+ * #117 that keksdose's hand-built chrome had fixed.
153
+ *
154
+ * The label is the field's static label: `top-1` and 11px in a `leading-tight` line, so
155
+ * its line box runs from 4px to 17.75px. Until 0.22 the chrome was `pt-4` over a 20px
156
+ * (`leading-5`) segment, so the ACTIVE segment's fill — painted from the top of the
157
+ * segment — started at 17px (1px border + 16px), 0.75px INSIDE the label's line box:
158
+ * the highlight touched the word above it, exactly what #117's rework had measured and
159
+ * cured with `pt-5`.
160
+ *
161
+ * Now the strip is `pt-5` and the fill starts at 21px, 3.25px clear, and the segment
162
+ * gives the 4px back so the field stays a labelled Select's 42px: `md` sets its 14px
163
+ * text in an 18px line with a 2px moat under it (the bare group's own `p-0.5` moat),
164
+ * `sm` sets 12px text in a 16px line over `pb-1` — keksdose's measured chrome
165
+ * (price-level-picker, reports-filters: `pt-5 pb-1`, `text-xs md:py-0`), byte for byte.
166
+ * Before, `size="sm"` inside the chrome was overridden to the md 20px line.
167
+ *
168
+ * md: 1 + 20 + 18 + 2 + 1 = 42 sm: 1 + 20 + 16 + 4 + 1 = 42
169
+ */
170
+ const CHROME_PAD: Record<"sm" | "md", string> = { md: "pt-5 pb-0.5", sm: "pt-5 pb-1" };
171
+ const CHROME_SEGMENT: Record<"sm" | "md", string> = { md: "py-0 leading-[18px]", sm: "py-0 leading-4" };
172
+
173
+ /**
174
+ * The strip placement's segments: a 26px group (1 + 2 + 20 + 2 + 1) under the 16px
175
+ * strip — `md`'s 20px text-sm line with no padding, `sm`'s 16px line with 2px either
176
+ * side. See `LABEL_STRIP_PAD` for why 16.
177
+ */
178
+ const STRIP_SEGMENT: Record<"sm" | "md", string> = { md: "py-0", sm: "py-0.5" };
179
+
101
180
  /**
102
181
  * A line under the group saying what the CHOSEN option means — lenkbank's ToggleField
103
182
  * `hint` (features/gear/common.tsx:97): not help behind a "?" but a caption, and one
@@ -189,18 +268,30 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
189
268
  size = "md",
190
269
  label,
191
270
  labelPlacement = "field",
271
+ chromeClassName,
192
272
  hint,
193
273
  error,
274
+ disabledReason: ownDisabledReason,
275
+ commit,
194
276
  "aria-label": ariaLabelAttr,
195
277
  ...restWithMode
196
278
  } = props;
197
279
  const labelId = useId();
198
280
  const errorId = useId();
199
- const labelled = label !== undefined && label !== null && label !== false && label !== "";
200
- // `field` is the chrome; a label placed above keeps the bare group's own box.
281
+ const reasonId = useId();
282
+ // The lock's reason under a locked provider (with `commit`), else the group's own.
283
+ const disabledReason = useCommitReason(commit, ownDisabledReason);
284
+ const locked = hasContent(disabledReason);
285
+ // Locked looks disabled — the dimmed group, the not-allowed cursor, no hover offer —
286
+ // but stays focusable; `disabled` alone also takes the segments out of the tab order.
287
+ const dimmed = disabled || locked;
288
+ const labelled = hasContent(label);
289
+ // `field` is the chrome; a label placed above or in a strip keeps the bare group's
290
+ // own box.
201
291
  const field = labelled && labelPlacement === "field";
202
292
  const above = labelled && labelPlacement === "above";
203
- const hasError = labelled && error !== undefined && error !== null && error !== false && error !== "";
293
+ const strip = labelled && labelPlacement === "strip";
294
+ const hasError = labelled && hasContent(error);
204
295
  // Taken off the rest so neither reaches the DOM; `props` keeps them paired, which is
205
296
  // what lets the `onChange` below be called with `null` only in the mode that allows it.
206
297
  const { allowEmpty: _allowEmpty, onChange: _onChange, caption, semantics: _semantics, ...rest } = restWithMode;
@@ -210,8 +301,11 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
210
301
  const captionId = useId();
211
302
  const captionIsLive = typeof caption === "function";
212
303
  const captionNode = captionIsLive ? (caption as (v: T | null) => ReactNode)(value) : caption;
213
- const hasCaption = captionNode !== undefined && captionNode !== null && captionNode !== false && captionNode !== "";
304
+ const hasCaption = hasContent(captionNode);
214
305
  const choose = (next: T) => {
306
+ // A locked group swallows the change: it saves on change, and a lock that let the
307
+ // value move and then discarded it would show a state that was never stored.
308
+ if (locked) return;
215
309
  if (props.allowEmpty) props.onChange(next === value ? null : next);
216
310
  else props.onChange(next);
217
311
  };
@@ -236,10 +330,79 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
236
330
  if (next === null) return;
237
331
  e.preventDefault();
238
332
  next = next < 0 ? last : next > last ? 0 : next;
333
+ // The segments' own parent: the group, or under a lock the tooltip's row inside it.
239
334
  const buttons = e.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>(":scope > button");
240
335
  buttons?.[next]?.focus();
336
+ // Locked: the focus walks (each segment says why on arrival), the choice does not.
241
337
  choose(options[next].value);
242
338
  };
339
+ const segments = options.map((opt, index) => {
340
+ const active = opt.value === value;
341
+ return (
342
+ <button
343
+ key={opt.value}
344
+ type="button"
345
+ role={pressed ? undefined : "radio"}
346
+ aria-checked={pressed ? undefined : active}
347
+ aria-pressed={pressed ? active : undefined}
348
+ // A reason wins over `disabled` (as on Button): reachable, and refusing.
349
+ disabled={disabled && !locked}
350
+ aria-disabled={locked || undefined}
351
+ // On each segment rather than the group: the segment is what takes focus, and
352
+ // a group's description is not read reliably when focus lands inside it.
353
+ aria-describedby={locked ? reasonId : undefined}
354
+ tabIndex={pressed ? undefined : opt.value === tabStop ? 0 : -1}
355
+ onKeyDown={pressed ? undefined : (e) => onRadioKey(e, index)}
356
+ onClick={() => choose(opt.value)}
357
+ className={cn(
358
+ // `truncate` (which carries whitespace-nowrap) rather than letting a
359
+ // label wrap: a segmented control sizes its whole row to the tallest
360
+ // option, so one two-word option — Keksdose feedback #147's "Where I
361
+ // am" — silently doubles the height of every segment beside it.
362
+ //
363
+ // `basis-auto` is what keeps that ellipsis a LAST resort rather than
364
+ // the normal state (Keksdose dev#475). With flex-1's `basis-0`, a
365
+ // shrink-to-fit group (`w-auto`) still resolves to the sum of the
366
+ // labels' widths — and then splits it EQUALLY, so the short option
367
+ // got 66px it did not need and "Where I am" got 66 of the 81 it did:
368
+ // truncated at 1778px of free screen. Basing each segment on its own
369
+ // content and sharing only the LEFTOVER space keeps a full-width
370
+ // group's segments near-equal and an auto-width group's exact.
371
+ //
372
+ // `focus-visible` + `ring-inset`, not `focus` + an outset ring. A ring
373
+ // is a box-shadow that spreads OUTWARD, so on a flush group it painted
374
+ // 2px of ring over both neighbours and over the container's own border
375
+ // — and on a phone it appeared on every TAP, because a tap focuses the
376
+ // button. That is the other half of what live #268's rework saw
377
+ // overlaying the selected segment's boundary. Inset keeps the ring
378
+ // inside the segment it belongs to; focus-visible keeps it for the
379
+ // keyboard, which is the only input that needs it.
380
+ overflow === "wrap" ? "whitespace-nowrap" : "min-w-0 truncate",
381
+ "flex-1 basis-auto rounded px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--border-strong)]",
382
+ size === "sm" && "px-2 py-1 text-xs",
383
+ // In the field chrome: no vertical padding, and the line that makes the
384
+ // field 42px under a strip that clears the label — see CHROME_PAD. `sm`
385
+ // keeps its 12px type here (it used to be overridden to the md line).
386
+ field && CHROME_SEGMENT[size],
387
+ // In the strip placement: a 26px group under a 16px strip — STRIP_SEGMENT.
388
+ strip && STRIP_SEGMENT[size],
389
+ active
390
+ ? "bg-[var(--bg-inverse)] text-[var(--text-inverse)]"
391
+ : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]",
392
+ // No hover fill on a group that cannot be changed — a segment that
393
+ // lights up under the pointer is an offer, and there is none here. Not on
394
+ // the chosen segment: its fill IS its state, and its inverse text would
395
+ // vanish on a transparent hover.
396
+ dimmed && "cursor-not-allowed",
397
+ dimmed && !active && "hover:bg-transparent dark:hover:bg-transparent",
398
+ optionClassName,
399
+ opt.className,
400
+ )}
401
+ >
402
+ {opt.label}
403
+ </button>
404
+ );
405
+ });
243
406
  const group = (
244
407
  <div
245
408
  // The audit's named example of a closed prop list (§"Public API design"): the
@@ -262,10 +425,10 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
262
425
  aria-describedby={
263
426
  [rest["aria-describedby"], hasCaption && captionId, hasError && errorId].filter(Boolean).join(" ") || undefined
264
427
  }
265
- // `aria-disabled` on the group as well as `disabled` on each button: a radio
266
- // group is what the user is being refused, and a screen reader announcing
428
+ // `aria-disabled` on the group as well as on (or `disabled` on) each button: a
429
+ // radio group is what the user is being refused, and a screen reader announcing
267
430
  // three separately-disabled radios does not say that.
268
- aria-disabled={disabled || undefined}
431
+ aria-disabled={dimmed || undefined}
269
432
  className={cn(
270
433
  // `gap-0.5` — the same 2px as the container's own padding, so EVERY segment
271
434
  // sits in a uniform 2px moat and no two fills ever touch. Flush segments were
@@ -284,75 +447,34 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
284
447
  // The whole group fades, the way every other disabled control in this
285
448
  // package does; `cursor-not-allowed` is on the buttons, which is what a
286
449
  // pointer is actually over.
287
- disabled && "opacity-60",
450
+ dimmed && "opacity-60",
288
451
  // The bare group (no chrome to paint) wears the invalid border itself.
289
- !field && (outsideInvalid || (above && hasError)) && FIELD_INVALID,
452
+ !field && (outsideInvalid || ((above || strip) && hasError)) && FIELD_INVALID,
290
453
  // Inside the field's chrome the group is only a row of segments: no border, no
291
454
  // surface, no padding of its own, and the field (not the group) is what dims.
292
455
  field && "border-0 bg-transparent p-0 shadow-none opacity-100",
293
456
  !labelled && className,
294
457
  )}
295
458
  >
296
- {options.map((opt, index) => {
297
- const active = opt.value === value;
298
- return (
299
- <button
300
- key={opt.value}
301
- type="button"
302
- role={pressed ? undefined : "radio"}
303
- aria-checked={pressed ? undefined : active}
304
- aria-pressed={pressed ? active : undefined}
305
- disabled={disabled}
306
- tabIndex={pressed ? undefined : opt.value === tabStop ? 0 : -1}
307
- onKeyDown={pressed ? undefined : (e) => onRadioKey(e, index)}
308
- onClick={() => choose(opt.value)}
309
- className={cn(
310
- // `truncate` (which carries whitespace-nowrap) rather than letting a
311
- // label wrap: a segmented control sizes its whole row to the tallest
312
- // option, so one two-word option — Keksdose feedback #147's "Where I
313
- // am" — silently doubles the height of every segment beside it.
314
- //
315
- // `basis-auto` is what keeps that ellipsis a LAST resort rather than
316
- // the normal state (Keksdose dev#475). With flex-1's `basis-0`, a
317
- // shrink-to-fit group (`w-auto`) still resolves to the sum of the
318
- // labels' widths — and then splits it EQUALLY, so the short option
319
- // got 66px it did not need and "Where I am" got 66 of the 81 it did:
320
- // truncated at 1778px of free screen. Basing each segment on its own
321
- // content and sharing only the LEFTOVER space keeps a full-width
322
- // group's segments near-equal and an auto-width group's exact.
323
- //
324
- // `focus-visible` + `ring-inset`, not `focus` + an outset ring. A ring
325
- // is a box-shadow that spreads OUTWARD, so on a flush group it painted
326
- // 2px of ring over both neighbours and over the container's own border
327
- // — and on a phone it appeared on every TAP, because a tap focuses the
328
- // button. That is the other half of what live #268's rework saw
329
- // overlaying the selected segment's boundary. Inset keeps the ring
330
- // inside the segment it belongs to; focus-visible keeps it for the
331
- // keyboard, which is the only input that needs it.
332
- overflow === "wrap" ? "whitespace-nowrap" : "min-w-0 truncate",
333
- "flex-1 basis-auto rounded px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--border-strong)]",
334
- size === "sm" && "px-2 py-1 text-xs",
335
- // In a field: no vertical padding and a 20px line — a `text-sm` line, the
336
- // same line a labelled Select holds under its label strip — so the field's
337
- // own `pt-4 pb-1` decides the height, as it does for the select.
338
- field && "py-0 leading-5",
339
- active
340
- ? "bg-[var(--bg-inverse)] text-[var(--text-inverse)]"
341
- : "text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]",
342
- // No hover fill on a group that cannot be changed — a segment that
343
- // lights up under the pointer is an offer, and there is none here. Not on
344
- // the chosen segment: its fill IS its state, and its inverse text would
345
- // vanish on a transparent hover.
346
- disabled && "cursor-not-allowed",
347
- disabled && !active && "hover:bg-transparent dark:hover:bg-transparent",
348
- optionClassName,
349
- opt.className,
350
- )}
351
- >
352
- {opt.label}
353
- </button>
354
- );
355
- })}
459
+ {locked ? (
460
+ // The reason's bubble wraps the SEGMENTS, inside the group, not the group: a
461
+ // wrapper outside it would become the flex or grid item in the caller's row in
462
+ // the group's place, and a `w-full` / `md:w-auto` on the group would size the
463
+ // wrong box. This row inherits the group's gap and wrap, so the segments lay out
464
+ // exactly as they do unlocked, and `:scope > button` above still finds them. A
465
+ // fragment child, so the Tooltip describes nothing itself — each segment points
466
+ // at the hidden copy, the way a locked Button does.
467
+ <Tooltip label={disabledReason} className="flex min-w-0 flex-1 gap-[inherit] [flex-wrap:inherit]">
468
+ <>
469
+ {segments}
470
+ <span id={reasonId} hidden>
471
+ {disabledReason}
472
+ </span>
473
+ </>
474
+ </Tooltip>
475
+ ) : (
476
+ segments
477
+ )}
356
478
  </div>
357
479
  );
358
480
  // Rendered whenever the caption is — `aria-live` must be on the element BEFORE its
@@ -384,7 +506,7 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
384
506
  by `aria-labelledby` on the group; the element keeps the Field look. */}
385
507
  <Label
386
508
  id={labelId}
387
- disabled={disabled}
509
+ disabled={dimmed}
388
510
  data-error={hasError || undefined}
389
511
  className="data-[error=true]:text-[var(--danger)]"
390
512
  >
@@ -400,6 +522,15 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
400
522
  </div>
401
523
  );
402
524
  }
525
+ if (strip) {
526
+ return (
527
+ <LabelStrip labelId={labelId} label={label} hint={hint} disabled={dimmed} pad="field" className={className}>
528
+ {group}
529
+ {captionEl}
530
+ {errorEl}
531
+ </LabelStrip>
532
+ );
533
+ }
403
534
  if (!field) {
404
535
  if (!captionEl) return group;
405
536
  // The group keeps its `className`, as without a caption; the wrapper only stacks.
@@ -422,9 +553,11 @@ export function ToggleGroup<T extends string>(props: ToggleGroupProps<T>): React
422
553
  >
423
554
  <div
424
555
  className={cn(
425
- "flex flex-1 flex-col justify-center rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-1 pt-4 pb-1 shadow-sm",
426
- disabled && "bg-[var(--bg-surface-2)] opacity-60",
556
+ "flex flex-1 flex-col justify-center rounded-md border border-[var(--border)] bg-[var(--bg-surface)] px-1 shadow-sm",
557
+ CHROME_PAD[size],
558
+ dimmed && "bg-[var(--bg-surface-2)] opacity-60",
427
559
  hasError && FIELD_INVALID,
560
+ chromeClassName,
428
561
  )}
429
562
  >
430
563
  {group}
@@ -14,6 +14,9 @@ export interface TriggerAria {
14
14
  "aria-labelledby"?: string;
15
15
  "aria-describedby"?: string;
16
16
  "aria-invalid"?: boolean | "true" | "false" | "grammar" | "spelling";
17
+ /** 0.23.0: a form library's `required` (RhfMonthPicker) is announced on the trigger,
18
+ * not lost on the wrapper. */
19
+ "aria-required"?: boolean | "true" | "false";
17
20
  }
18
21
 
19
22
  /** Split a field's props into what its trigger takes and what its wrapper takes. */
@@ -26,6 +29,7 @@ export function splitTriggerAria<T extends TriggerAria>(
26
29
  "aria-labelledby": labelledBy,
27
30
  "aria-describedby": describedBy,
28
31
  "aria-invalid": invalid,
32
+ "aria-required": required,
29
33
  ...rest
30
34
  } = props;
31
35
  return [
@@ -35,6 +39,7 @@ export function splitTriggerAria<T extends TriggerAria>(
35
39
  "aria-labelledby": labelledBy,
36
40
  "aria-describedby": describedBy,
37
41
  "aria-invalid": invalid,
42
+ "aria-required": required,
38
43
  },
39
44
  rest,
40
45
  ];