@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
@@ -10,6 +10,11 @@
10
10
  * <RhfSelect name="type" label="Type" options={TYPES} />
11
11
  * <RhfCombobox name="tenantId" label="Tenant" options={tenants} clearable />
12
12
  * <RhfCheckbox name="isDefault" label="Default account" />
13
+ * <RhfTimeInput name="meetingTime" label="Time" />
14
+ * <RhfDateRangePicker fromName="periodFrom" toName="periodTo" label="Period" />
15
+ * <RhfToggleGroup name="interval" label="Interval" options={INTERVALS} />
16
+ * <RhfCountrySelect name="country" label="Country" preferred={["CH", "DE"]} />
17
+ * <RhfMonthPicker name="fiscalYear" label="Fiscal year" mode="year" valueAsNumber />
13
18
  * </Form>
14
19
  * ```
15
20
  *
@@ -41,19 +46,36 @@
41
46
  * {@link RhfField} is the shell they are all built on, for a control the kit does not
42
47
  * ship (an address autocomplete): it takes a `render` and wires the rest.
43
48
  */
44
- import { useLayoutEffect, useRef, useState, type ReactElement, type ReactNode } from "react";
45
- import type {
46
- ControllerFieldState,
47
- ControllerProps,
48
- ControllerRenderProps,
49
- FieldPath,
50
- FieldValues,
49
+ import { useLayoutEffect, useRef, useState, type FocusEvent, type ReactElement, type ReactNode } from "react";
50
+ import {
51
+ useController,
52
+ type ControllerFieldState,
53
+ type ControllerProps,
54
+ type ControllerRenderProps,
55
+ type FieldPath,
56
+ type FieldValues,
51
57
  } from "react-hook-form";
52
58
  import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, useFormField } from "./form";
53
59
  import { Input, Select, Textarea, type InputProps, type SelectProps, type TextareaProps } from "../components/ui";
54
60
  import { NumberField, type NumberFieldProps } from "../components/number-field";
55
61
  import { AmountInput } from "../components/amount-input";
56
- import { DatePicker, type DatePickerProps } from "../components/date-picker";
62
+ import {
63
+ DatePicker,
64
+ DateRangePicker,
65
+ type DatePickerProps,
66
+ type DateRangePickerProps,
67
+ } from "../components/date-picker";
68
+ import { TimeInput, type TimeInputProps } from "../components/time-input";
69
+ import { IbanInput, type IbanInputProps } from "../components/iban-input";
70
+ import { PhoneInput, type PhoneInputProps } from "../components/phone-input";
71
+ import { CountrySelect, type CountrySelectProps } from "../components/country-select";
72
+ import { MonthPicker, type MonthPickerProps } from "../components/month-picker";
73
+ import {
74
+ ToggleGroup,
75
+ type ToggleGroupBaseProps,
76
+ type ToggleGroupClearableProps,
77
+ type ToggleGroupRequiredProps,
78
+ } from "../components/toggle-group";
57
79
  import { Checkbox, type CheckboxProps } from "../components/checkbox";
58
80
  import { EntityCombobox, type EntityComboboxProps } from "../components/entity-combobox";
59
81
  import { Combobox, type ComboboxProps } from "../components/combobox";
@@ -553,6 +575,10 @@ export type RhfMoneyFieldProps<
553
575
  * with `rules` as well if a clamp needs explaining. */
554
576
  min?: number;
555
577
  max?: number;
578
+ /** Classes for the `<input>` — `className` is the item's box. {@link RhfTextField},
579
+ * {@link RhfTextarea} and {@link RhfNumberField} take it; since 0.23 AmountInput
580
+ * does too (keksdose G7: `text-end` for a money column). */
581
+ inputClassName?: string;
556
582
  };
557
583
 
558
584
  /** The amount a text reads as, or `undefined` for a draft that is not one yet. */
@@ -679,7 +705,9 @@ export type RhfDateFieldProps<
679
705
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
680
706
  TTransformed = TFieldValues,
681
707
  > = RhfFieldBaseProps<TFieldValues, TName, TTransformed> &
682
- Omit<DatePickerProps, "value" | "onChange" | "label" | "invalid" | "disabled" | "className" | "id"> & {
708
+ // `error`: the form's message is shown by the binding (FormMessage); the picker's own
709
+ // `error` (0.22) would show it a second time.
710
+ Omit<DatePickerProps, "value" | "onChange" | "label" | "invalid" | "disabled" | "className" | "id" | "error"> & {
683
711
  /** Classes for the picker. Default `w-full`. */
684
712
  inputClassName?: string;
685
713
  /** What a cleared date stores. Default `""`, the empty ISO string; `null` for a
@@ -766,6 +794,663 @@ export function RhfDateField<
766
794
  );
767
795
  }
768
796
 
797
+ // ── date range ───────────────────────────────────────────────────────────────
798
+
799
+ type OwnDateRangeProps = Omit<
800
+ DateRangePickerProps,
801
+ "from" | "to" | "onChange" | "label" | "invalid" | "disabled" | "className" | "id" | "error"
802
+ >;
803
+
804
+ export type RhfDateRangePickerProps<
805
+ TFieldValues extends FieldValues = FieldValues,
806
+ TFromName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
807
+ TToName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
808
+ TTransformed = TFieldValues,
809
+ > = Omit<RhfFieldBaseProps<TFieldValues, TFromName, TTransformed>, "name" | "rules"> &
810
+ OwnDateRangeProps & {
811
+ /** The form field holding the range's first day, an ISO `"YYYY-MM-DD"`. */
812
+ fromName: TFromName;
813
+ /** The form field holding the range's last day. */
814
+ toName: TToName;
815
+ /** react-hook-form's `rules` for the start (`required`, a `validate` …). */
816
+ fromRules?: ControllerProps<TFieldValues, TFromName, TTransformed>["rules"];
817
+ /** react-hook-form's `rules` for the end — the place for "not before the start",
818
+ * which can read the start with `getValues`: the start is always written first. */
819
+ toRules?: ControllerProps<TFieldValues, TToName, TTransformed>["rules"];
820
+ /** Classes for the picker. Default `w-full`. */
821
+ inputClassName?: string;
822
+ /** What an empty end stores — a clear, or the half-made range between the first
823
+ * and second click. Default `""`, the empty ISO string; `null` for a nullable
824
+ * schema. */
825
+ emptyValue?: "" | null;
826
+ };
827
+
828
+ /**
829
+ * The kit's {@link DateRangePicker} over TWO form fields — `fromName` and `toName` —
830
+ * the way a period is stored: as two columns, each its own schema key, not one
831
+ * `{ from, to }` object (kastlan 4).
832
+ *
833
+ * ```tsx
834
+ * <RhfDateRangePicker fromName="periodFrom" toName="periodTo" label="Billing period"
835
+ * presets={presets} clearable />
836
+ * ```
837
+ *
838
+ * Why it is not {@link RhfField}: a `Controller` binds one name, and the range is
839
+ * two. Built by hand — an `RhfField` on the start and a second `useController` for
840
+ * the end, which is what kastlan's period fields needed — it works until the END is
841
+ * the one in error: the shell's label, message and `aria-invalid` all read the
842
+ * start's state alone, so "End before start" (a `refine` with `path: ["periodTo"]`,
843
+ * where such a rule goes) never shows. Here both states are read:
844
+ *
845
+ * - **Either field's error is shown and paints.** The label turns, the trigger is
846
+ * `aria-invalid` and wears the danger border when EITHER field has an error, and
847
+ * both messages are listed under the field (once, if the two say the same thing),
848
+ * each pointed at by the trigger's `aria-describedby`.
849
+ * - **One change writes both.** The picker reports the range whole, so the start and
850
+ * then the end are written on every change — the end too when only the start
851
+ * moved, so a cross-field rule on the end re-runs and an "End before start" clears
852
+ * the moment the start is fixed. A clear (`clearable`'s ×) empties both.
853
+ * - **Focus on error** lands on the trigger whichever of the two is in error.
854
+ * - **Touched** once a range is complete or cleared — not on the first click of a
855
+ * pick, which would mark (and, with `mode: "onTouched"`, validate) an end the user
856
+ * is still choosing.
857
+ *
858
+ * In `commit="immediate"` mode (the picker's default) the first calendar click is
859
+ * reported on its own, so the form holds `(from, emptyValue)` until the second; with
860
+ * `commit="apply"` nothing reaches the form until Apply.
861
+ *
862
+ * The common props (`label`, `hint`, `required`, `disabled`, `excludeWhenDisabled`,
863
+ * `control`, `className`) apply to the pair; `rules` are per field.
864
+ */
865
+ export function RhfDateRangePicker<
866
+ TFieldValues extends FieldValues = FieldValues,
867
+ TFromName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
868
+ TToName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
869
+ TTransformed = TFieldValues,
870
+ >({
871
+ fromName,
872
+ toName,
873
+ control,
874
+ fromRules,
875
+ toRules,
876
+ label,
877
+ hint,
878
+ required,
879
+ disabled,
880
+ excludeWhenDisabled,
881
+ className,
882
+ inputClassName,
883
+ emptyValue = "",
884
+ ...pickerProps
885
+ }: RhfDateRangePickerProps<TFieldValues, TFromName, TToName, TTransformed>) {
886
+ const rhfDisabled = excludeWhenDisabled ? disabled : undefined;
887
+ // The end, read here; the start is the FormField below, which gives the label, hint
888
+ // and ids their context. This component re-renders on the end's state, and with it
889
+ // the FormField's render.
890
+ const to = useController<TFieldValues, TToName, TTransformed>({
891
+ name: toName,
892
+ control,
893
+ rules: toRules,
894
+ disabled: rhfDisabled,
895
+ });
896
+ return (
897
+ <FormField
898
+ control={control}
899
+ name={fromName}
900
+ rules={fromRules}
901
+ disabled={rhfDisabled}
902
+ render={({ field, fieldState }) => (
903
+ <FormItem className={className}>
904
+ <RangeBody
905
+ from={field as unknown as ControllerRenderProps}
906
+ fromState={fieldState}
907
+ to={to.field as unknown as ControllerRenderProps}
908
+ toState={to.fieldState}
909
+ label={label}
910
+ hint={hint}
911
+ required={required}
912
+ disabled={Boolean(disabled)}
913
+ emptyValue={emptyValue}
914
+ inputClassName={inputClassName}
915
+ pickerProps={pickerProps}
916
+ />
917
+ </FormItem>
918
+ )}
919
+ />
920
+ );
921
+ }
922
+
923
+ function RangeBody({
924
+ from,
925
+ fromState,
926
+ to,
927
+ toState,
928
+ label,
929
+ hint,
930
+ required,
931
+ disabled,
932
+ emptyValue,
933
+ inputClassName,
934
+ pickerProps,
935
+ }: {
936
+ from: ControllerRenderProps;
937
+ fromState: ControllerFieldState;
938
+ to: ControllerRenderProps;
939
+ toState: ControllerFieldState;
940
+ label: ReactNode;
941
+ hint: ReactNode;
942
+ required?: boolean;
943
+ disabled: boolean;
944
+ emptyValue: "" | null;
945
+ inputClassName?: string;
946
+ pickerProps: OwnDateRangeProps;
947
+ }) {
948
+ const { id, formItemId, formMessageId, describedBy } = useFormField();
949
+ // The trigger forwards no ref; both fields focus it, so `shouldFocusError` lands
950
+ // there whichever of the two is first in error.
951
+ useFocusHandle(from.ref, () => document.getElementById(formItemId));
952
+ useFocusHandle(to.ref, () => document.getElementById(formItemId));
953
+ const invalid = fromState.invalid || toState.invalid;
954
+ const messages = [fromState.error?.message, toState.error?.message].filter(
955
+ (m, i, all): m is string => typeof m === "string" && m !== "" && all.indexOf(m) === i,
956
+ );
957
+ const messageIds = messages.map((_, i) => `${formMessageId}-${i}`);
958
+ const fromValue: unknown = from.value;
959
+ const toValue: unknown = to.value;
960
+ return (
961
+ <>
962
+ {hasContent(label) && (
963
+ // FormLabel colours from the START's error alone (its context is that field);
964
+ // the pair's state says it here.
965
+ <FormLabel id={`${id}-label`} required={required} data-error={invalid || undefined}>
966
+ {label}
967
+ </FormLabel>
968
+ )}
969
+ <DateRangePicker
970
+ {...pickerProps}
971
+ id={formItemId}
972
+ // Merged, never replaced — a caller's own reference first, as FormControl does.
973
+ aria-describedby={
974
+ [pickerProps["aria-describedby"], describedBy, ...messageIds].filter(Boolean).join(" ") || undefined
975
+ }
976
+ aria-invalid={invalid || undefined}
977
+ aria-required={required || undefined}
978
+ from={typeof fromValue === "string" ? fromValue : ""}
979
+ to={typeof toValue === "string" ? toValue : ""}
980
+ onChange={(f, t) => {
981
+ from.onChange(f === "" ? emptyValue : f);
982
+ to.onChange(t === "" ? emptyValue : t);
983
+ if (t !== "" || f === "") {
984
+ from.onBlur();
985
+ to.onBlur();
986
+ }
987
+ }}
988
+ disabled={disabled || from.disabled || to.disabled}
989
+ invalid={invalid}
990
+ className={inputClassName ?? "w-full"}
991
+ />
992
+ {hasContent(hint) && <FormDescription>{hint}</FormDescription>}
993
+ {messages.map((message, i) => (
994
+ // FormMessage's look and its rule: no `role="alert"` — read when focus reaches
995
+ // the trigger, which names these in its description.
996
+ <p
997
+ key={message}
998
+ id={messageIds[i]}
999
+ data-slot="form-message"
1000
+ className="text-[11px] leading-tight text-[var(--danger)]"
1001
+ >
1002
+ {message}
1003
+ </p>
1004
+ ))}
1005
+ </>
1006
+ );
1007
+ }
1008
+
1009
+ // ── time ─────────────────────────────────────────────────────────────────────
1010
+
1011
+ export type RhfTimeInputProps<
1012
+ TFieldValues extends FieldValues = FieldValues,
1013
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
1014
+ TTransformed = TFieldValues,
1015
+ > = RhfFieldBaseProps<TFieldValues, TName, TTransformed> &
1016
+ Omit<
1017
+ TimeInputProps,
1018
+ | "name"
1019
+ | "value"
1020
+ | "defaultValue"
1021
+ | "onChange"
1022
+ | "onValueChange"
1023
+ | "onBlur"
1024
+ | "label"
1025
+ | "error"
1026
+ | "invalid"
1027
+ | "className"
1028
+ | "disabled"
1029
+ | "required"
1030
+ > & {
1031
+ /** What a cleared time stores. Default `""`; `null` for a nullable schema. */
1032
+ emptyValue?: "" | null;
1033
+ };
1034
+
1035
+ /**
1036
+ * The kit's {@link TimeInput}: a time of day as `"HH:mm"` (`"HH:mm:ss"` when `step`
1037
+ * asks for seconds), normalised before it reaches the form, so the stored value
1038
+ * compares and sorts as a string. kastlan 4: the owner meeting's time was an
1039
+ * {@link RhfField} with a hand-wired `TimeInput` in its render — `ref`, `name`, the
1040
+ * `?? ""`, `onValueChange`, `onBlur`, `invalid` — the seven lines every bound field
1041
+ * folds into one.
1042
+ *
1043
+ * `min` / `max` paint an out-of-window time (see TimeInput) but do not fail the form:
1044
+ * validation stays the schema's or `rules`'. `rules={{ validate: (v) =>
1045
+ * isTimeInRange(v, min, max) || "…" }}` says it with the same window.
1046
+ */
1047
+ export function RhfTimeInput<
1048
+ TFieldValues extends FieldValues = FieldValues,
1049
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
1050
+ TTransformed = TFieldValues,
1051
+ >({
1052
+ name,
1053
+ control,
1054
+ rules,
1055
+ label,
1056
+ hint,
1057
+ required,
1058
+ disabled,
1059
+ excludeWhenDisabled,
1060
+ className,
1061
+ emptyValue = "",
1062
+ ...timeProps
1063
+ }: RhfTimeInputProps<TFieldValues, TName, TTransformed>) {
1064
+ return (
1065
+ <RhfField
1066
+ {...{ name, control, rules, label, hint, required, disabled, excludeWhenDisabled, className }}
1067
+ render={({ field, invalid }) => {
1068
+ const value: unknown = field.value;
1069
+ return (
1070
+ <TimeInput
1071
+ aria-required={required || undefined}
1072
+ {...timeProps}
1073
+ name={field.name}
1074
+ ref={field.ref}
1075
+ value={typeof value === "string" ? value : ""}
1076
+ onValueChange={(v) => field.onChange(v === "" ? emptyValue : v)}
1077
+ onBlur={field.onBlur}
1078
+ disabled={field.disabled}
1079
+ invalid={invalid}
1080
+ />
1081
+ );
1082
+ }}
1083
+ />
1084
+ );
1085
+ }
1086
+
1087
+ // ── IBAN and phone (0.22) ────────────────────────────────────────────────────
1088
+
1089
+ type OwnTextishProps<P> = Omit<
1090
+ P,
1091
+ | "name"
1092
+ | "value"
1093
+ | "defaultValue"
1094
+ | "onChange"
1095
+ | "onValueChange"
1096
+ | "onBlur"
1097
+ | "label"
1098
+ | "error"
1099
+ | "invalid"
1100
+ | "className"
1101
+ | "disabled"
1102
+ | "required"
1103
+ >;
1104
+
1105
+ export type RhfIbanInputProps<
1106
+ TFieldValues extends FieldValues = FieldValues,
1107
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
1108
+ TTransformed = TFieldValues,
1109
+ > = RhfFieldBaseProps<TFieldValues, TName, TTransformed> & OwnTextishProps<IbanInputProps>;
1110
+
1111
+ /**
1112
+ * {@link IbanInput} bound to one field, which stores the COMPACT upper-case IBAN (what
1113
+ * kastlan's server checks and its QR bill prints). The field's own checksum and
1114
+ * `kind` messages show while the form has nothing to say; a form error (the schema's
1115
+ * or `rules`') replaces them, as a caller's `error` does on the bare field. kastlan
1116
+ * asked for the field; it binds everything through react-hook-form.
1117
+ */
1118
+ export function RhfIbanInput<
1119
+ TFieldValues extends FieldValues = FieldValues,
1120
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
1121
+ TTransformed = TFieldValues,
1122
+ >({
1123
+ name,
1124
+ control,
1125
+ rules,
1126
+ label,
1127
+ hint,
1128
+ required,
1129
+ disabled,
1130
+ excludeWhenDisabled,
1131
+ className,
1132
+ ...ibanProps
1133
+ }: RhfIbanInputProps<TFieldValues, TName, TTransformed>) {
1134
+ return (
1135
+ <RhfField
1136
+ {...{ name, control, rules, label, hint, required, disabled, excludeWhenDisabled, className }}
1137
+ render={({ field, invalid }) => {
1138
+ const value: unknown = field.value;
1139
+ return (
1140
+ <IbanInput
1141
+ aria-required={required || undefined}
1142
+ {...ibanProps}
1143
+ name={field.name}
1144
+ ref={field.ref}
1145
+ value={typeof value === "string" ? value : ""}
1146
+ onValueChange={field.onChange}
1147
+ onBlur={field.onBlur}
1148
+ disabled={field.disabled}
1149
+ invalid={invalid}
1150
+ />
1151
+ );
1152
+ }}
1153
+ />
1154
+ );
1155
+ }
1156
+
1157
+ export type RhfPhoneInputProps<
1158
+ TFieldValues extends FieldValues = FieldValues,
1159
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
1160
+ TTransformed = TFieldValues,
1161
+ > = RhfFieldBaseProps<TFieldValues, TName, TTransformed> & OwnTextishProps<PhoneInputProps>;
1162
+
1163
+ /**
1164
+ * {@link PhoneInput} bound to one field, which stores E.164 when the number reads as
1165
+ * one and the typed text unchanged otherwise — kastlan's contacts keep their old free
1166
+ * text until someone edits it. Validation (required, a pattern for E.164 only) is the
1167
+ * schema's or `rules`'.
1168
+ */
1169
+ export function RhfPhoneInput<
1170
+ TFieldValues extends FieldValues = FieldValues,
1171
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
1172
+ TTransformed = TFieldValues,
1173
+ >({
1174
+ name,
1175
+ control,
1176
+ rules,
1177
+ label,
1178
+ hint,
1179
+ required,
1180
+ disabled,
1181
+ excludeWhenDisabled,
1182
+ className,
1183
+ ...phoneProps
1184
+ }: RhfPhoneInputProps<TFieldValues, TName, TTransformed>) {
1185
+ return (
1186
+ <RhfField
1187
+ {...{ name, control, rules, label, hint, required, disabled, excludeWhenDisabled, className }}
1188
+ render={({ field, invalid }) => {
1189
+ const value: unknown = field.value;
1190
+ return (
1191
+ <PhoneInput
1192
+ aria-required={required || undefined}
1193
+ {...phoneProps}
1194
+ name={field.name}
1195
+ ref={field.ref}
1196
+ value={typeof value === "string" ? value : ""}
1197
+ onValueChange={field.onChange}
1198
+ onBlur={field.onBlur}
1199
+ disabled={field.disabled}
1200
+ invalid={invalid}
1201
+ />
1202
+ );
1203
+ }}
1204
+ />
1205
+ );
1206
+ }
1207
+
1208
+ // ── country and month (0.23) ─────────────────────────────────────────────────
1209
+
1210
+ type OwnCountrySelectProps = Omit<
1211
+ CountrySelectProps<boolean>,
1212
+ | "value"
1213
+ | "onChange"
1214
+ | "onBlur"
1215
+ | "label"
1216
+ | "hint"
1217
+ | "error"
1218
+ | "invalid"
1219
+ | "disabled"
1220
+ | "className"
1221
+ | "id"
1222
+ | "ref"
1223
+ | "clearable"
1224
+ >;
1225
+
1226
+ export type RhfCountrySelectProps<
1227
+ TFieldValues extends FieldValues = FieldValues,
1228
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
1229
+ TTransformed = TFieldValues,
1230
+ > = RhfFieldBaseProps<TFieldValues, TName, TTransformed> &
1231
+ OwnCountrySelectProps & {
1232
+ /** Offer the picker's clear "×" (and Delete on the trigger) while a country is
1233
+ * chosen. A clear stores `clearValue`. */
1234
+ clearable?: boolean;
1235
+ /** What a clear stores. Default `null`; `""` for a schema that spells "no
1236
+ * country" as an empty string (a zod `z.string().max(2)`, kastlan's). The picker
1237
+ * reads either back as empty. */
1238
+ clearValue?: ComboClearValue;
1239
+ /** Classes for the picker — `className` is the item's box. */
1240
+ selectClassName?: string;
1241
+ };
1242
+
1243
+ /**
1244
+ * {@link CountrySelect} bound to one field, which stores the ISO 3166-1 alpha-2 code
1245
+ * ("CH", always upper-case), or `clearValue` once cleared (`clearable`). kastlan asked
1246
+ * for it (0.22 adoption): its address form wired a `CountrySelect` through
1247
+ * {@link RhfField}'s render, and with no ref on the picker react-hook-form's
1248
+ * focus-on-error had nothing to focus — the one field on the form a failed submit
1249
+ * could not take you to.
1250
+ *
1251
+ * Bound the way {@link RhfIbanInput} and {@link RhfPhoneInput} are: `field.ref` reaches
1252
+ * the trigger (the picker's `ref`, 0.23), the form's error paints the trigger and is
1253
+ * the shell's message under it, the hint is the shell's description — so neither is
1254
+ * ever handed to the picker, and its box never changes when one comes or goes. The
1255
+ * form's label names the trigger together with the chosen country, and is the phone
1256
+ * sheet's title. `commit` and `disabledReason` are the picker's own: a country that
1257
+ * saves itself stays focusable under a lock and says why.
1258
+ *
1259
+ * ```tsx
1260
+ * <RhfCountrySelect name="country" label="Country" required
1261
+ * preferred={["CH", "LI", "DE", "AT", "FR", "IT"]} rules={{ required: "Choose a country" }} />
1262
+ * ```
1263
+ */
1264
+ export function RhfCountrySelect<
1265
+ TFieldValues extends FieldValues = FieldValues,
1266
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
1267
+ TTransformed = TFieldValues,
1268
+ >({
1269
+ name,
1270
+ control,
1271
+ rules,
1272
+ label,
1273
+ hint,
1274
+ required,
1275
+ disabled,
1276
+ excludeWhenDisabled,
1277
+ className,
1278
+ clearable,
1279
+ clearValue = null,
1280
+ selectClassName,
1281
+ labels,
1282
+ ...countryProps
1283
+ }: RhfCountrySelectProps<TFieldValues, TName, TTransformed>) {
1284
+ const labelled = hasContent(label);
1285
+ // A string label is also what the field IS — the picker's `country` word, which
1286
+ // titles the phone sheet. A caller's own `labels.country` still wins.
1287
+ const ownLabels = typeof label === "string" ? { country: label, ...labels } : labels;
1288
+ return (
1289
+ <RhfField
1290
+ {...{ name, control, rules, label, hint, required, disabled, excludeWhenDisabled, className }}
1291
+ render={({ field, invalid, labelId }) => {
1292
+ const value: unknown = field.value;
1293
+ return (
1294
+ // FormControl clones this element with `id` / `aria-describedby` /
1295
+ // `aria-invalid`, which the picker routes to its trigger.
1296
+ <CountrySelect<boolean>
1297
+ aria-required={required || undefined}
1298
+ // The form's label, then the country (see the picker's `aria-labelledby`).
1299
+ aria-labelledby={labelled ? labelId : undefined}
1300
+ {...countryProps}
1301
+ labels={ownLabels}
1302
+ className={selectClassName}
1303
+ ref={field.ref}
1304
+ value={typeof value === "string" ? value : null}
1305
+ clearable={clearable}
1306
+ onChange={(code) => field.onChange(code ?? clearValue)}
1307
+ // Focus leaving the picker (the wrapper's focusout) marks the field touched.
1308
+ onBlur={field.onBlur}
1309
+ disabled={field.disabled}
1310
+ invalid={invalid}
1311
+ />
1312
+ );
1313
+ }}
1314
+ />
1315
+ );
1316
+ }
1317
+
1318
+ type OwnMonthPickerProps = Omit<
1319
+ MonthPickerProps,
1320
+ "value" | "onChange" | "onBlur" | "label" | "hint" | "error" | "invalid" | "disabled" | "className" | "id" | "mode"
1321
+ >;
1322
+
1323
+ /** `mode` picks what the form stores, and `valueAsNumber` is only offered for a year. */
1324
+ type RhfMonthPickerMode =
1325
+ | {
1326
+ /** A month, stored as `"YYYY-MM"` (the default). */
1327
+ mode?: "month";
1328
+ valueAsNumber?: undefined;
1329
+ }
1330
+ | {
1331
+ /** A year, stored as `"YYYY"` — or as a number with `valueAsNumber`. */
1332
+ mode: "year";
1333
+ /** Store the year as a number (`2026`) rather than `"2026"` — kastlan's budget
1334
+ * `fiscal_year` is an integer column. A number in the form reads back as its
1335
+ * year. */
1336
+ valueAsNumber?: boolean;
1337
+ };
1338
+
1339
+ export type RhfMonthPickerProps<
1340
+ TFieldValues extends FieldValues = FieldValues,
1341
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
1342
+ TTransformed = TFieldValues,
1343
+ > = RhfFieldBaseProps<TFieldValues, TName, TTransformed> &
1344
+ OwnMonthPickerProps & {
1345
+ /** Classes for the picker — `className` is the item's box. As {@link RhfDateField}'s. */
1346
+ inputClassName?: string;
1347
+ } & RhfMonthPickerMode;
1348
+
1349
+ function MonthControl({
1350
+ field,
1351
+ invalid,
1352
+ id,
1353
+ valueAsNumber,
1354
+ mode,
1355
+ inputClassName,
1356
+ pickerProps,
1357
+ required,
1358
+ ...aria
1359
+ }: {
1360
+ field: ControllerRenderProps;
1361
+ invalid: boolean;
1362
+ id: string;
1363
+ valueAsNumber?: boolean;
1364
+ mode?: "month" | "year";
1365
+ inputClassName?: string;
1366
+ pickerProps: OwnMonthPickerProps;
1367
+ required?: boolean;
1368
+ "aria-describedby"?: string;
1369
+ "aria-invalid"?: MonthPickerProps["aria-invalid"];
1370
+ }) {
1371
+ // MonthPicker forwards no ref; it routes `id` to its trigger, where the handle finds
1372
+ // it — RhfDateField's way.
1373
+ useFocusHandle(field.ref, () => document.getElementById(id));
1374
+ const value: unknown = field.value;
1375
+ return (
1376
+ <MonthPicker
1377
+ {...pickerProps}
1378
+ {...aria}
1379
+ // MonthPicker routes `aria-required` to its trigger since 0.23.0.
1380
+ aria-required={required || undefined}
1381
+ id={id}
1382
+ mode={mode}
1383
+ value={value === null || value === undefined ? "" : String(value)}
1384
+ onChange={(key) => {
1385
+ field.onChange(mode === "year" && valueAsNumber ? Number(key) : key);
1386
+ // A pick is the whole interaction — there is no text to leave — so it touches.
1387
+ field.onBlur();
1388
+ }}
1389
+ disabled={field.disabled}
1390
+ invalid={invalid}
1391
+ className={inputClassName}
1392
+ />
1393
+ );
1394
+ }
1395
+
1396
+ /**
1397
+ * {@link MonthPicker} bound to one field: a month as `"YYYY-MM"`, or with `mode="year"` a
1398
+ * year as `"YYYY"` (`valueAsNumber` stores `2026`). kastlan asked for it with
1399
+ * {@link RhfCountrySelect}: its budget wizard's fiscal year was a `MonthPicker
1400
+ * mode="year"` inside {@link RhfField}'s render — the `String()` in, the `Number()`
1401
+ * out, `invalid` by hand — and a failed submit could not focus it, because the picker
1402
+ * forwards no ref.
1403
+ *
1404
+ * Bound as {@link RhfDateField} is: a focus handle finds the trigger by its id, so
1405
+ * focus-on-error lands there; the form's error paints the trigger and is the shell's
1406
+ * message; the hint is the shell's description; neither is handed to the picker, so
1407
+ * its box never changes. Picking marks the field touched.
1408
+ *
1409
+ * ```tsx
1410
+ * <RhfMonthPicker name="fiscalYear" label="Fiscal year" mode="year" valueAsNumber
1411
+ * min="2020" max="2029" rules={{ required: "Choose a year" }} />
1412
+ * ```
1413
+ */
1414
+ export function RhfMonthPicker<
1415
+ TFieldValues extends FieldValues = FieldValues,
1416
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
1417
+ TTransformed = TFieldValues,
1418
+ >({
1419
+ name,
1420
+ control,
1421
+ rules,
1422
+ label,
1423
+ hint,
1424
+ required,
1425
+ disabled,
1426
+ excludeWhenDisabled,
1427
+ className,
1428
+ inputClassName,
1429
+ mode,
1430
+ valueAsNumber,
1431
+ ...pickerProps
1432
+ }: RhfMonthPickerProps<TFieldValues, TName, TTransformed>) {
1433
+ return (
1434
+ <RhfField
1435
+ {...{ name, control, rules, label, hint, required, disabled, excludeWhenDisabled, className }}
1436
+ render={({ field, invalid, id }) => (
1437
+ // FormControl clones this element with `aria-describedby` / `aria-invalid`,
1438
+ // which MonthControl hands on to the picker's trigger.
1439
+ <MonthControl
1440
+ field={field as unknown as ControllerRenderProps}
1441
+ invalid={invalid}
1442
+ id={id}
1443
+ mode={mode}
1444
+ valueAsNumber={valueAsNumber}
1445
+ inputClassName={inputClassName}
1446
+ pickerProps={pickerProps}
1447
+ required={required}
1448
+ />
1449
+ )}
1450
+ />
1451
+ );
1452
+ }
1453
+
769
1454
  // ── select ───────────────────────────────────────────────────────────────────
770
1455
 
771
1456
  export interface RhfSelectOption {
@@ -900,6 +1585,172 @@ export function RhfCheckbox<
900
1585
  );
901
1586
  }
902
1587
 
1588
+ // ── toggle group ─────────────────────────────────────────────────────────────
1589
+
1590
+ /** The two shapes a {@link ToggleGroup} comes in, carried over: `allowEmpty` picks
1591
+ * whether a press on the chosen option clears it, and types the caption to match. */
1592
+ type RhfToggleGroupMode<T extends string> =
1593
+ | {
1594
+ /** One option is always the answer (the default): a press on the chosen
1595
+ * option re-sends it. */
1596
+ allowEmpty?: false;
1597
+ semantics?: ToggleGroupRequiredProps<T>["semantics"];
1598
+ caption?: ToggleGroupRequiredProps<T>["caption"];
1599
+ emptyValue?: undefined;
1600
+ }
1601
+ | {
1602
+ /** A press on the chosen option clears it, and the form stores `emptyValue`. */
1603
+ allowEmpty: true;
1604
+ semantics?: ToggleGroupClearableProps<T>["semantics"];
1605
+ caption?: ToggleGroupClearableProps<T>["caption"];
1606
+ /** What a cleared group stores. Default `null`; `""` for a schema that spells
1607
+ * "no choice" as an empty string. */
1608
+ emptyValue?: null | "";
1609
+ };
1610
+
1611
+ export type RhfToggleGroupProps<
1612
+ TFieldValues extends FieldValues = FieldValues,
1613
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
1614
+ T extends string = string,
1615
+ TTransformed = TFieldValues,
1616
+ > = RhfFieldBaseProps<TFieldValues, TName, TTransformed> &
1617
+ Pick<
1618
+ ToggleGroupBaseProps<T>,
1619
+ "options" | "optionClassName" | "overflow" | "size" | "aria-label" | "commit" | "disabledReason"
1620
+ > & {
1621
+ /** Classes for the group itself — `className` is the item's box. */
1622
+ groupClassName?: string;
1623
+ } & RhfToggleGroupMode<T>;
1624
+
1625
+ /**
1626
+ * The kit's {@link ToggleGroup} bound to one field: a choice between a few answers
1627
+ * ("Monthly / Quarterly / Yearly"), stored as the chosen option's `value`. kastlan 4
1628
+ * asked for it with {@link RhfTimeInput} and {@link RhfDateRangePicker}: the controls
1629
+ * its forms still had to wire by hand through {@link RhfField}'s render.
1630
+ *
1631
+ * The label is the form's {@link FormLabel} above the bare group, as on every other
1632
+ * bound field — the `labelPlacement="above"` shape, not the group's own field chrome —
1633
+ * and it names the group (`aria-labelledby`); the hint and the message describe it,
1634
+ * and an error paints the group's border. Focus on error goes to the option a
1635
+ * keyboard user would land on (the chosen one, else the first); touched is set when
1636
+ * focus leaves the group, not when it moves between options.
1637
+ *
1638
+ * Required by default, as the group is: a press on the chosen option keeps it. With
1639
+ * `allowEmpty`, a second press clears the choice and the form stores `emptyValue`
1640
+ * (`null` by default) — the options then become toggle buttons, see ToggleGroup. A
1641
+ * value that matches no option (an empty default) leaves nothing pressed; that the
1642
+ * field still needs answering is the schema's or `rules`' to say.
1643
+ */
1644
+ export function RhfToggleGroup<
1645
+ TFieldValues extends FieldValues = FieldValues,
1646
+ TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
1647
+ T extends string = string,
1648
+ TTransformed = TFieldValues,
1649
+ >({
1650
+ name,
1651
+ control,
1652
+ rules,
1653
+ label,
1654
+ hint,
1655
+ required,
1656
+ disabled,
1657
+ excludeWhenDisabled,
1658
+ className,
1659
+ ...groupProps
1660
+ }: RhfToggleGroupProps<TFieldValues, TName, T, TTransformed>) {
1661
+ const labelled = hasContent(label);
1662
+ return (
1663
+ <RhfField
1664
+ {...{ name, control, rules, label, hint, required, disabled, excludeWhenDisabled, className }}
1665
+ asControl={false}
1666
+ render={({ field, invalid, id, labelId, describedBy }) => (
1667
+ <ToggleControl<T>
1668
+ field={field as unknown as ControllerRenderProps}
1669
+ invalid={invalid}
1670
+ required={required}
1671
+ id={id}
1672
+ labelledBy={labelled ? labelId : undefined}
1673
+ describedBy={describedBy}
1674
+ groupProps={groupProps}
1675
+ />
1676
+ )}
1677
+ />
1678
+ );
1679
+ }
1680
+
1681
+ function ToggleControl<T extends string>({
1682
+ field,
1683
+ invalid,
1684
+ required,
1685
+ id,
1686
+ labelledBy,
1687
+ describedBy,
1688
+ groupProps,
1689
+ }: {
1690
+ field: ControllerRenderProps;
1691
+ invalid: boolean;
1692
+ required?: boolean;
1693
+ id: string;
1694
+ labelledBy: string | undefined;
1695
+ describedBy: string | undefined;
1696
+ groupProps: Omit<RhfToggleGroupProps<FieldValues, string, T>, keyof RhfFieldBaseProps>;
1697
+ }) {
1698
+ const { groupClassName, emptyValue, allowEmpty, semantics, caption, ...rest } = groupProps;
1699
+ // ToggleGroup forwards no ref. Its options are buttons: the radio shape's one tab
1700
+ // stop (`tabindex="0"`), or in the pressed shape the pressed one — else the first.
1701
+ useFocusHandle(field.ref, () => {
1702
+ const group = document.getElementById(id);
1703
+ return (
1704
+ group?.querySelector<HTMLElement>('button[tabindex="0"], button[aria-pressed="true"]') ??
1705
+ group?.querySelector<HTMLElement>("button")
1706
+ );
1707
+ });
1708
+ // React's onBlur is focusout: it fires on every move between options as well, and
1709
+ // only leaving the group is a blur of the FIELD.
1710
+ const onBlur = (e: FocusEvent<HTMLDivElement>) => {
1711
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null)) field.onBlur();
1712
+ };
1713
+ const raw: unknown = field.value;
1714
+ const value = raw === null || raw === undefined || raw === "" ? null : (String(raw) as T);
1715
+ const common = {
1716
+ ...rest,
1717
+ id,
1718
+ className: groupClassName,
1719
+ "aria-labelledby": labelledBy,
1720
+ "aria-describedby": describedBy,
1721
+ "aria-invalid": invalid || undefined,
1722
+ disabled: field.disabled,
1723
+ onBlur,
1724
+ };
1725
+ // The props' union pairs `allowEmpty` with its caption's type; taken apart above it no
1726
+ // longer narrows, so each branch says which half it is.
1727
+ if (allowEmpty) {
1728
+ return (
1729
+ <ToggleGroup<T>
1730
+ {...common}
1731
+ allowEmpty
1732
+ caption={caption as ToggleGroupClearableProps<T>["caption"]}
1733
+ value={value}
1734
+ onChange={(v) => field.onChange(v ?? emptyValue ?? null)}
1735
+ />
1736
+ );
1737
+ }
1738
+ return (
1739
+ <ToggleGroup<T>
1740
+ {...common}
1741
+ allowEmpty={false}
1742
+ semantics={semantics}
1743
+ caption={caption as ToggleGroupRequiredProps<T>["caption"]}
1744
+ // A radiogroup takes `aria-required`; a group of toggle buttons does not.
1745
+ aria-required={required && semantics !== "pressed" ? true : undefined}
1746
+ // An empty default matches no option: nothing pressed, the first option the tab
1747
+ // stop.
1748
+ value={(value ?? "") as T}
1749
+ onChange={(v) => field.onChange(v)}
1750
+ />
1751
+ );
1752
+ }
1753
+
903
1754
  // ── comboboxes ───────────────────────────────────────────────────────────────
904
1755
 
905
1756
  export type RhfComboboxProps<