@eifi1/ui-kit 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (278) hide show
  1. package/dist/chart.d.ts +10 -8
  2. package/dist/components/account-chips.d.ts +10 -3
  3. package/dist/components/account-chips.js +5 -2
  4. package/dist/components/account-chips.js.map +1 -1
  5. package/dist/components/account-settings.d.ts +33 -2
  6. package/dist/components/account-settings.js +43 -21
  7. package/dist/components/account-settings.js.map +1 -1
  8. package/dist/components/amount-input.d.ts +27 -8
  9. package/dist/components/amount-input.js +15 -28
  10. package/dist/components/amount-input.js.map +1 -1
  11. package/dist/components/autocomplete.d.ts +8 -0
  12. package/dist/components/autocomplete.js +9 -4
  13. package/dist/components/autocomplete.js.map +1 -1
  14. package/dist/components/button-group.d.ts +10 -8
  15. package/dist/components/calculator.d.ts +10 -8
  16. package/dist/components/checkbox-group.d.ts +104 -0
  17. package/dist/components/checkbox-group.js +111 -0
  18. package/dist/components/checkbox-group.js.map +1 -0
  19. package/dist/components/checkbox.d.ts +19 -0
  20. package/dist/components/checkbox.js +30 -7
  21. package/dist/components/checkbox.js.map +1 -1
  22. package/dist/components/chip.d.ts +18 -0
  23. package/dist/components/chip.js +47 -10
  24. package/dist/components/chip.js.map +1 -1
  25. package/dist/components/combobox.d.ts +13 -2
  26. package/dist/components/combobox.js +17 -8
  27. package/dist/components/combobox.js.map +1 -1
  28. package/dist/components/confirm-dialog.d.ts +66 -161
  29. package/dist/components/copy-button.d.ts +10 -8
  30. package/dist/components/country-select.d.ts +124 -0
  31. package/dist/components/country-select.js +237 -0
  32. package/dist/components/country-select.js.map +1 -0
  33. package/dist/components/currency-select.d.ts +17 -1
  34. package/dist/components/currency-select.js +56 -23
  35. package/dist/components/currency-select.js.map +1 -1
  36. package/dist/components/danger-confirm.d.ts +66 -166
  37. package/dist/components/danger-confirm.js +133 -49
  38. package/dist/components/danger-confirm.js.map +1 -1
  39. package/dist/components/data-table-cells.d.ts +1 -2
  40. package/dist/components/data-table-filter-popover.d.ts +1 -2
  41. package/dist/components/data-table-filters.d.ts +1 -2
  42. package/dist/components/data-table-labels.d.ts +4 -97
  43. package/dist/components/data-table-labels.js +9 -1
  44. package/dist/components/data-table-labels.js.map +1 -1
  45. package/dist/components/data-table-pagination.d.ts +3 -1
  46. package/dist/components/data-table.d.ts +1 -2
  47. package/dist/components/data-table.js +98 -3
  48. package/dist/components/data-table.js.map +1 -1
  49. package/dist/components/date-picker.d.ts +25 -5
  50. package/dist/components/date-picker.js +110 -59
  51. package/dist/components/date-picker.js.map +1 -1
  52. package/dist/components/entity-combobox.d.ts +8 -1
  53. package/dist/components/entity-combobox.js +9 -4
  54. package/dist/components/entity-combobox.js.map +1 -1
  55. package/dist/components/facing-pair.d.ts +10 -8
  56. package/dist/components/field-anatomy.d.ts +95 -0
  57. package/dist/components/field-anatomy.js +84 -0
  58. package/dist/components/field-anatomy.js.map +1 -0
  59. package/dist/components/field-parts.d.ts +114 -0
  60. package/dist/components/field-parts.js +72 -0
  61. package/dist/components/field-parts.js.map +1 -0
  62. package/dist/components/file-button.d.ts +10 -8
  63. package/dist/components/file-button.js +13 -3
  64. package/dist/components/file-button.js.map +1 -1
  65. package/dist/components/file-dropzone.d.ts +32 -9
  66. package/dist/components/file-dropzone.js +57 -34
  67. package/dist/components/file-dropzone.js.map +1 -1
  68. package/dist/components/form-actions.d.ts +10 -8
  69. package/dist/components/form-actions.js +61 -3
  70. package/dist/components/form-actions.js.map +1 -1
  71. package/dist/components/iban-input.d.ts +66 -0
  72. package/dist/components/iban-input.js +125 -0
  73. package/dist/components/iban-input.js.map +1 -0
  74. package/dist/components/icon-picker.d.ts +12 -2
  75. package/dist/components/icon-picker.js +9 -2
  76. package/dist/components/icon-picker.js.map +1 -1
  77. package/dist/components/inline-edit-field.d.ts +167 -0
  78. package/dist/components/inline-edit-field.js +293 -0
  79. package/dist/components/inline-edit-field.js.map +1 -0
  80. package/dist/components/language-select.d.ts +127 -0
  81. package/dist/components/language-select.js +18 -0
  82. package/dist/components/language-select.js.map +1 -0
  83. package/dist/components/line-items.d.ts +25 -3
  84. package/dist/components/line-items.js +55 -46
  85. package/dist/components/line-items.js.map +1 -1
  86. package/dist/components/list.d.ts +25 -0
  87. package/dist/components/list.js +16 -5
  88. package/dist/components/list.js.map +1 -1
  89. package/dist/components/money-field.d.ts +20 -8
  90. package/dist/components/money-field.js.map +1 -1
  91. package/dist/components/month-picker.d.ts +52 -4
  92. package/dist/components/month-picker.js +290 -86
  93. package/dist/components/month-picker.js.map +1 -1
  94. package/dist/components/multi-select.d.ts +8 -1
  95. package/dist/components/multi-select.js +9 -4
  96. package/dist/components/multi-select.js.map +1 -1
  97. package/dist/components/number-field.d.ts +25 -13
  98. package/dist/components/number-field.js +6 -20
  99. package/dist/components/number-field.js.map +1 -1
  100. package/dist/components/number-input.d.ts +70 -13
  101. package/dist/components/number-input.js +90 -38
  102. package/dist/components/number-input.js.map +1 -1
  103. package/dist/components/numpad-sheet.d.ts +10 -8
  104. package/dist/components/one-time-code-input.d.ts +100 -0
  105. package/dist/components/one-time-code-input.js +137 -0
  106. package/dist/components/one-time-code-input.js.map +1 -0
  107. package/dist/components/phone-input.d.ts +66 -0
  108. package/dist/components/phone-input.js +171 -0
  109. package/dist/components/phone-input.js.map +1 -0
  110. package/dist/components/series-chart.d.ts +10 -8
  111. package/dist/components/settings-fields.d.ts +10 -8
  112. package/dist/components/share-card.d.ts +8 -6
  113. package/dist/components/sign-chip.d.ts +66 -0
  114. package/dist/components/sign-chip.js +39 -0
  115. package/dist/components/sign-chip.js.map +1 -0
  116. package/dist/components/swatch-picker.d.ts +27 -2
  117. package/dist/components/swatch-picker.js +73 -31
  118. package/dist/components/swatch-picker.js.map +1 -1
  119. package/dist/components/switch.d.ts +19 -0
  120. package/dist/components/switch.js +24 -6
  121. package/dist/components/switch.js.map +1 -1
  122. package/dist/components/text-link.d.ts +10 -8
  123. package/dist/components/tile-radio.d.ts +68 -13
  124. package/dist/components/tile-radio.js +3 -3
  125. package/dist/components/tile-radio.js.map +1 -1
  126. package/dist/components/time-input.d.ts +15 -9
  127. package/dist/components/time-input.js.map +1 -1
  128. package/dist/components/toggle-group.d.ts +47 -1
  129. package/dist/components/toggle-group.js +110 -69
  130. package/dist/components/toggle-group.js.map +1 -1
  131. package/dist/components/ui.d.ts +10 -8
  132. package/dist/components/ui.js +280 -78
  133. package/dist/components/ui.js.map +1 -1
  134. package/dist/components/use-table-state.d.ts +1 -2
  135. package/dist/{data-table-3mi2R3so.d.ts → data-table-labels-B7OdnM0S.d.ts} +140 -3
  136. package/dist/data-table.d.ts +1 -2
  137. package/dist/data-table.js +2 -1
  138. package/dist/data-table.js.map +1 -1
  139. package/dist/feedback/feedback-attachment.d.ts +1 -1
  140. package/dist/feedback/feedback-attachment.js +210 -3
  141. package/dist/feedback/feedback-attachment.js.map +1 -1
  142. package/dist/feedback/feedback-dialog.d.ts +1 -1
  143. package/dist/feedback/feedback-dialog.js.map +1 -1
  144. package/dist/feedback/feedback-inbox.d.ts +1 -1
  145. package/dist/feedback/feedback-thread.d.ts +37 -3
  146. package/dist/feedback/feedback-thread.js +38 -9
  147. package/dist/feedback/feedback-thread.js.map +1 -1
  148. package/dist/{feedback-attachment-WFiXJ8We.d.ts → feedback-attachment-NhUm7Zga.d.ts} +70 -5
  149. package/dist/feedback.d.ts +2 -2
  150. package/dist/hooks/use-file-drop.d.ts +10 -8
  151. package/dist/i18n/defaults.d.ts +10 -8
  152. package/dist/i18n/defaults.js +17 -2
  153. package/dist/i18n/defaults.js.map +1 -1
  154. package/dist/i18n/german.d.ts +10 -8
  155. package/dist/i18n/german.js +50 -5
  156. package/dist/i18n/german.js.map +1 -1
  157. package/dist/i18n/kit-labels.d.ts +10 -8
  158. package/dist/i18n/kit-labels.js +8 -2
  159. package/dist/i18n/kit-labels.js.map +1 -1
  160. package/dist/i18n/languages.d.ts +10 -8
  161. package/dist/i18n/locales/de-CH.d.ts +10 -8
  162. package/dist/i18n/locales/en.d.ts +10 -8
  163. package/dist/i18n/locales/en.js +9 -0
  164. package/dist/i18n/locales/en.js.map +1 -1
  165. package/dist/i18n/locales/es.d.ts +10 -8
  166. package/dist/i18n/locales/es.js +48 -5
  167. package/dist/i18n/locales/es.js.map +1 -1
  168. package/dist/i18n/locales/fr.d.ts +10 -8
  169. package/dist/i18n/locales/fr.js +48 -5
  170. package/dist/i18n/locales/fr.js.map +1 -1
  171. package/dist/i18n/locales/hu.d.ts +10 -8
  172. package/dist/i18n/locales/hu.js +49 -5
  173. package/dist/i18n/locales/hu.js.map +1 -1
  174. package/dist/i18n/locales/it.d.ts +10 -8
  175. package/dist/i18n/locales/it.js +49 -5
  176. package/dist/i18n/locales/it.js.map +1 -1
  177. package/dist/i18n/locales/zh.d.ts +10 -8
  178. package/dist/i18n/locales/zh.js +48 -5
  179. package/dist/i18n/locales/zh.js.map +1 -1
  180. package/dist/i18n/review.d.ts +10 -8
  181. package/dist/i18n/review.js +8 -1
  182. package/dist/i18n/review.js.map +1 -1
  183. package/dist/index.d.ts +17 -10
  184. package/dist/index.js +57 -2
  185. package/dist/index.js.map +1 -1
  186. package/dist/{kit-labels-D1J2_jyD.d.ts → kit-labels-R8mIAc9N.d.ts} +811 -8
  187. package/dist/lib/countries.d.ts +43 -0
  188. package/dist/lib/countries.js +39 -0
  189. package/dist/lib/countries.js.map +1 -0
  190. package/dist/lib/decimal-marks.d.ts +46 -0
  191. package/dist/lib/decimal-marks.js +40 -0
  192. package/dist/lib/decimal-marks.js.map +1 -0
  193. package/dist/lib/iban.d.ts +98 -0
  194. package/dist/lib/iban.js +154 -0
  195. package/dist/lib/iban.js.map +1 -0
  196. package/dist/lib/isin.d.ts +30 -0
  197. package/dist/lib/isin.js +36 -0
  198. package/dist/lib/isin.js.map +1 -0
  199. package/dist/lib/phone.d.ts +69 -0
  200. package/dist/lib/phone.js +121 -0
  201. package/dist/lib/phone.js.map +1 -0
  202. package/dist/rhf/fields.d.ts +148 -11
  203. package/dist/rhf/fields.js +332 -3
  204. package/dist/rhf/fields.js.map +1 -1
  205. package/dist/rhf/form.d.ts +10 -8
  206. package/dist/rhf.d.ts +13 -9
  207. package/dist/rhf.js.map +1 -1
  208. package/dist/shell/app-shell.d.ts +10 -8
  209. package/dist/shell/top-bar-brand.d.ts +10 -8
  210. package/dist/shell.d.ts +10 -8
  211. package/dist/tour/tour.js +20 -24
  212. package/dist/tour/tour.js.map +1 -1
  213. package/dist/wizard/stepper-nav.d.ts +10 -8
  214. package/dist/wizard.d.ts +10 -8
  215. package/package.json +1 -1
  216. package/src/components/account-chips.tsx +17 -3
  217. package/src/components/account-settings.tsx +92 -26
  218. package/src/components/amount-input.tsx +49 -56
  219. package/src/components/autocomplete.tsx +133 -110
  220. package/src/components/checkbox-group.tsx +229 -0
  221. package/src/components/checkbox.tsx +72 -8
  222. package/src/components/chip.tsx +80 -10
  223. package/src/components/combobox.tsx +292 -253
  224. package/src/components/country-select.tsx +443 -0
  225. package/src/components/currency-select.tsx +109 -33
  226. package/src/components/danger-confirm.tsx +345 -77
  227. package/src/components/data-table-labels.ts +16 -1
  228. package/src/components/data-table.tsx +175 -6
  229. package/src/components/date-picker.tsx +173 -62
  230. package/src/components/entity-combobox.tsx +107 -87
  231. package/src/components/field-anatomy.tsx +190 -0
  232. package/src/components/field-parts.tsx +184 -0
  233. package/src/components/file-button.tsx +36 -3
  234. package/src/components/file-dropzone.tsx +106 -38
  235. package/src/components/form-actions.tsx +115 -2
  236. package/src/components/iban-input.tsx +231 -0
  237. package/src/components/icon-picker.tsx +26 -4
  238. package/src/components/inline-edit-field.tsx +533 -0
  239. package/src/components/language-select.tsx +78 -0
  240. package/src/components/line-items.tsx +35 -2
  241. package/src/components/list.tsx +55 -6
  242. package/src/components/money-field.tsx +10 -0
  243. package/src/components/month-picker.tsx +399 -63
  244. package/src/components/multi-select.tsx +80 -58
  245. package/src/components/number-field.tsx +28 -46
  246. package/src/components/number-input.tsx +210 -43
  247. package/src/components/one-time-code-input.tsx +288 -0
  248. package/src/components/phone-input.tsx +299 -0
  249. package/src/components/sign-chip.tsx +103 -0
  250. package/src/components/swatch-picker.tsx +118 -30
  251. package/src/components/switch.tsx +58 -7
  252. package/src/components/tile-radio.tsx +71 -16
  253. package/src/components/time-input.tsx +8 -1
  254. package/src/components/toggle-group.tsx +206 -73
  255. package/src/components/ui.tsx +472 -105
  256. package/src/data-table.ts +2 -1
  257. package/src/feedback/feedback-attachment.tsx +313 -9
  258. package/src/feedback/feedback-dialog.tsx +2 -0
  259. package/src/feedback/feedback-thread.tsx +66 -5
  260. package/src/i18n/defaults.ts +16 -1
  261. package/src/i18n/german.ts +47 -0
  262. package/src/i18n/kit-labels.tsx +78 -2
  263. package/src/i18n/locales/en.ts +10 -0
  264. package/src/i18n/locales/es.ts +46 -0
  265. package/src/i18n/locales/fr.ts +45 -0
  266. package/src/i18n/locales/hu.ts +46 -0
  267. package/src/i18n/locales/it.ts +47 -0
  268. package/src/i18n/locales/zh.ts +44 -0
  269. package/src/i18n/review.ts +7 -0
  270. package/src/index.ts +53 -3
  271. package/src/lib/countries.ts +91 -0
  272. package/src/lib/decimal-marks.ts +99 -0
  273. package/src/lib/iban.ts +171 -0
  274. package/src/lib/isin.ts +62 -0
  275. package/src/lib/phone.ts +211 -0
  276. package/src/rhf/fields.tsx +606 -9
  277. package/src/rhf.ts +2 -1
  278. package/src/tour/tour.tsx +66 -28
@@ -4,11 +4,12 @@ import { Calendar, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { dirOf, horizontalStep, type Direction } from "../lib/direction";
6
6
  import { monthKey, pad } from "../lib/dates";
7
- import { Button, FieldLabel, FIELD_FLOATING_PAD, FIELD_INVALID, FIELD_TRIGGER, IconButton } from "./ui";
7
+ import { Button, FIELD_FLOATING_PAD, FIELD_INVALID, FIELD_TRIGGER, IconButton } from "./ui";
8
8
  import { Popover } from "./popover";
9
9
  import { splitTriggerAria } from "./trigger-aria";
10
10
  import type { TriggerAria } from "./trigger-aria";
11
- import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
11
+ import { useKitDateFormatter, useKitLabels, useKitLocale } from "../i18n/kit-labels";
12
+ import { FieldBox, FieldLabelLine, hasContent, useFieldMessages } from "./field-anatomy";
12
13
 
13
14
  /**
14
15
  * Every string the month picker can speak. The month and year names are NOT here —
@@ -32,6 +33,15 @@ export interface MonthPickerLabels {
32
33
  nextMonth: string;
33
34
  /** `variant="stepper"`: the button that jumps to the current month. Visible text. */
34
35
  today: string;
36
+ /** `mode="year"`: the popover's accessible name. */
37
+ yearPanel: string;
38
+ /** `mode="year"`: the icon-only arrows that page the year grid back and forward by a
39
+ * page of twelve years. */
40
+ earlierYears: string;
41
+ laterYears: string;
42
+ /** `mode="year"`, `variant="stepper"`: the button that jumps to the current year.
43
+ * Visible text. */
44
+ thisYear: string;
35
45
  }
36
46
 
37
47
  /** The English starting point every override is merged onto. Exported (unlike the
@@ -45,6 +55,10 @@ export const DEFAULT_MONTH_PICKER_LABELS: MonthPickerLabels = {
45
55
  previousMonth: "Previous month",
46
56
  nextMonth: "Next month",
47
57
  today: "Today",
58
+ yearPanel: "Choose a year",
59
+ earlierYears: "Earlier years",
60
+ laterYears: "Later years",
61
+ thisYear: "This year",
48
62
  };
49
63
 
50
64
  /** Merge caller overrides onto the English defaults. */
@@ -89,7 +103,45 @@ export interface MonthPickerProps extends Omit<ComponentPropsWithoutRef<"div">,
89
103
  disabled?: boolean;
90
104
  /** Required and unanswered — {@link FIELD_INVALID}. See {@link Input}'s `invalid`. */
91
105
  invalid?: boolean;
92
- /** Intl options for the trigger's text (default: long month + numeric year). */
106
+ /**
107
+ * What is wrong with the month, in the caller's words — {@link Input}'s `error`
108
+ * (keksdose K4): under the field, attached to the trigger with `aria-describedby`
109
+ * (merged, after any caption), and implies `invalid`. Passing the key keeps the
110
+ * field's box while there is no message, so the trigger is never remounted.
111
+ */
112
+ error?: ReactNode;
113
+ /** Standing advice, as on a {@link Select}: text is a caption under the field; a
114
+ * {@link FieldHint} rides the label line. */
115
+ hint?: ReactNode;
116
+ /**
117
+ * `"md"` (default): the form field. `"sm"`: the toolbar trigger — 36px tall (an
118
+ * `IconButton`'s height) and as wide as the month it shows, for a picker that sits
119
+ * between two icon buttons in a page header (keksdose K13: the budget header wrote
120
+ * `triggerClassName="h-9 w-auto … py-0"` by hand, live #260 — 34px between two 36px
121
+ * buttons, *"the kind of 2px that reads as sloppy"*). Unlabelled pickers only, as on
122
+ * {@link Select}: a floating label needs the tall box to float in. The width follows
123
+ * the month's name; add a `min-w-*` in `triggerClassName` if the buttons beside it
124
+ * must not move as it changes.
125
+ */
126
+ size?: "sm" | "md";
127
+ /**
128
+ * `"month"` (default): the value is a month, `"YYYY-MM"`.
129
+ *
130
+ * `"year"`: the value is a YEAR, `"YYYY"`, and the panel is a grid of years — kastlan's
131
+ * budget fiscal year (a `Select` of 2020 to three years ahead) and keksdose's tax tab
132
+ * ("this year and four before", tax-tab.tsx:33). `min` / `max` are read as their year
133
+ * (`"2022"`, or any longer key). A bounded span of twelve years or fewer is shown
134
+ * whole, with no paging; otherwise the grid pages twelve years at a time (PageUp /
135
+ * PageDown, or the arrows), starting from `min` when there is one. The stepper
136
+ * variant steps a year, and its Today button reads "This year".
137
+ *
138
+ * A string rather than a number, for the reason the month key is one: it is the
139
+ * same type across both modes, it crosses a URL as is, and it sorts. A numeric
140
+ * caller converts with `String(year)` and `Number(key)`.
141
+ */
142
+ mode?: "month" | "year";
143
+ /** Intl options for the trigger's text (default: long month + numeric year, or the
144
+ * numeric year in `mode="year"`). Win over the provider's `formatDate`. */
93
145
  formatOptions?: Intl.DateTimeFormatOptions;
94
146
  /**
95
147
  * The month to ring as "now", as `"YYYY-MM"`. Defaults to the local calendar's
@@ -109,8 +161,9 @@ export interface MonthPickerProps extends Omit<ComponentPropsWithoutRef<"div">,
109
161
  * `"stepper"`: the header of a month PAGE — the month as a heading that opens the
110
162
  * grid, then Today and ‹ › stepping the value by one month. What a calendar page
111
163
  * assembled by hand around the field (overriding its trigger to look like a
112
- * heading). `label`, `placeholder`, `invalid` and `triggerClassName` are the field's
113
- * and are not drawn here; with no `value` the arrows step from the current month.
164
+ * heading). `label`, `placeholder`, `invalid`, `hint`, `error`, `size` and
165
+ * `triggerClassName` are the field's and are not drawn here; with no `value` the
166
+ * arrows step from the current month (or year).
114
167
  */
115
168
  variant?: "field" | "stepper";
116
169
  /** `variant="stepper"`: the heading level the month is set in (`<h2>` by default), or
@@ -160,6 +213,17 @@ function firstOf({ year, month }: YearMonth): Date {
160
213
 
161
214
  const DEFAULT_TRIGGER_FORMAT: Intl.DateTimeFormatOptions = { month: "long", year: "numeric" };
162
215
  const MONTH_NAME_FORMAT: Intl.DateTimeFormatOptions = { month: "long", year: "numeric" };
216
+ const YEAR_FORMAT: Intl.DateTimeFormatOptions = { year: "numeric" };
217
+
218
+ // ── Year keys ─────────────────────────────────────────────────────────────
219
+
220
+ const YEAR = /^(\d{4})/;
221
+
222
+ /** The year of `"YYYY"`, `"YYYY-MM"` or `"YYYY-MM-DD"`, or null. */
223
+ function parseYear(key: string | undefined): number | null {
224
+ const m = key ? YEAR.exec(key) : null;
225
+ return m ? Number(m[1]) : null;
226
+ }
163
227
 
164
228
  // ── Trigger ───────────────────────────────────────────────────────────────
165
229
 
@@ -247,6 +311,7 @@ function MonthFieldTrigger({
247
311
  valueId,
248
312
  panelId,
249
313
  padded,
314
+ small,
250
315
  disabled,
251
316
  invalid,
252
317
  className,
@@ -263,6 +328,8 @@ function MonthFieldTrigger({
263
328
  valueId: string;
264
329
  panelId: string;
265
330
  padded: boolean;
331
+ /** `size="sm"`: the 36px, content-wide toolbar trigger. */
332
+ small: boolean;
266
333
  disabled?: boolean;
267
334
  invalid?: boolean;
268
335
  className?: string;
@@ -288,6 +355,10 @@ function MonthFieldTrigger({
288
355
  FIELD_TRIGGER,
289
356
  "pe-9",
290
357
  padded && FIELD_FLOATING_PAD,
358
+ // keksdose K13 (live #260): an IconButton's 36px, and only as wide as the
359
+ // month — `py-0` with a fixed height so the box is 36px whatever the type's
360
+ // line height. `pe-9` stays: the calendar glyph still sits at the end.
361
+ small && "h-9 w-auto py-0",
291
362
  disabled && "cursor-not-allowed opacity-50",
292
363
  invalid && FIELD_INVALID,
293
364
  className,
@@ -522,6 +593,196 @@ function MonthGrid({
522
593
  );
523
594
  }
524
595
 
596
+ /** Years on one page of the year grid: the month grid's 4×3. */
597
+ const YEAR_PAGE = 12;
598
+
599
+ /**
600
+ * `mode="year"`'s panel: a page of years between ‹ › arrows — {@link MonthGrid}'s shape
601
+ * and keyboard, one level up (kastlan 7, keksdose tax-tab.tsx:33).
602
+ *
603
+ * Which years a page holds:
604
+ * - Bounded on both sides by twelve years or fewer: exactly those years, and no
605
+ * arrows — keksdose's "this year and four before" is five cells, not a page of
606
+ * twelve with seven greyed out.
607
+ * - Otherwise twelve at a time, starting from `min` when there is one (so the first
608
+ * page opens on it), else ending at `max`, else on multiples of twelve.
609
+ *
610
+ * The arrow keys walk the years (up/down by a row of three), PageUp / PageDown a page,
611
+ * Home / End the row; walking off a page turns it. Focus stops at the bounds rather
612
+ * than walking into years that refuse selection: a page has no "next year" past the
613
+ * last selectable one worth landing on.
614
+ */
615
+ function YearGrid({
616
+ value,
617
+ onPick,
618
+ locale,
619
+ minYear,
620
+ maxYear,
621
+ currentYear,
622
+ labels,
623
+ }: {
624
+ value: number | null;
625
+ onPick: (key: string) => void;
626
+ locale: string | undefined;
627
+ minYear: number | null;
628
+ maxYear: number | null;
629
+ currentYear: number;
630
+ labels: MonthPickerLabels;
631
+ }) {
632
+ const clamp = (y: number) => Math.min(maxYear ?? y, Math.max(minYear ?? y, y));
633
+ // A bounded span that fits one page is shown whole.
634
+ const whole =
635
+ minYear !== null && maxYear !== null && maxYear >= minYear && maxYear - minYear < YEAR_PAGE
636
+ ? { first: minYear, count: maxYear - minYear + 1 }
637
+ : null;
638
+ // The year holding the tab order, and with it the page on screen — one piece of
639
+ // state, as in MonthGrid. Opens on the value, or on this year, inside the bounds.
640
+ const [active, setActive] = useState(() => clamp(value ?? currentYear));
641
+ const anchor = minYear ?? (maxYear !== null ? maxYear - (YEAR_PAGE - 1) : 0);
642
+ const first = whole ? whole.first : anchor + Math.floor((active - anchor) / YEAR_PAGE) * YEAR_PAGE;
643
+ const count = whole ? whole.count : YEAR_PAGE;
644
+ const years = Array.from({ length: count }, (_, i) => first + i);
645
+ const outOfBounds = (y: number) => (minYear !== null && y < minYear) || (maxYear !== null && y > maxYear);
646
+
647
+ const gridRef = useRef<HTMLDivElement>(null);
648
+ const captionId = `${useId()}-years`;
649
+ const yearFormat = useMemo(() => new Intl.DateTimeFormat(locale, YEAR_FORMAT), [locale]);
650
+ // "2016–2027" in the locale's own range punctuation and digits.
651
+ const caption = yearFormat.formatRange(new Date(first, 0, 1), new Date(first + count - 1, 0, 1));
652
+
653
+ const pendingFocus = useRef<number | null>(null);
654
+ useEffect(() => {
655
+ const year = pendingFocus.current;
656
+ if (year === null) return;
657
+ pendingFocus.current = null;
658
+ gridRef.current?.querySelector<HTMLElement>(`[data-year="${year}"]`)?.focus();
659
+ });
660
+ // Opened to pick a year: land on it (before the popover's trap, which keeps it).
661
+ useEffect(() => {
662
+ gridRef.current?.querySelector<HTMLElement>('[tabindex="0"]')?.focus();
663
+ }, []);
664
+
665
+ const moveTo = (next: number, focus: boolean) => {
666
+ const year = clamp(next);
667
+ if (focus) pendingFocus.current = year;
668
+ setActive(year);
669
+ };
670
+
671
+ const onCellKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
672
+ const step = horizontalStep(e.key, e.currentTarget);
673
+ const col = (active - first) % COLUMNS;
674
+ let delta: number;
675
+ switch (step ? "horizontal" : e.key) {
676
+ case "horizontal":
677
+ delta = step;
678
+ break;
679
+ case "ArrowUp":
680
+ delta = -COLUMNS;
681
+ break;
682
+ case "ArrowDown":
683
+ delta = COLUMNS;
684
+ break;
685
+ case "Home":
686
+ delta = -col;
687
+ break;
688
+ case "End":
689
+ delta = Math.min(COLUMNS - 1 - col, first + count - 1 - active);
690
+ break;
691
+ case "PageUp":
692
+ delta = -YEAR_PAGE;
693
+ break;
694
+ case "PageDown":
695
+ delta = YEAR_PAGE;
696
+ break;
697
+ default:
698
+ return;
699
+ }
700
+ e.preventDefault();
701
+ if (delta !== 0) moveTo(active + delta, true);
702
+ };
703
+
704
+ const prevBlocked = minYear !== null && first - 1 < minYear;
705
+ const nextBlocked = maxYear !== null && first + count > maxYear;
706
+ const arrow =
707
+ "flex size-7 items-center justify-center rounded text-[var(--text-muted)] hover:bg-[var(--bg-hover)] disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent";
708
+
709
+ return (
710
+ <div className="select-none">
711
+ <div className={cn("mb-1 flex items-center", whole ? "h-7 justify-center" : "justify-between")}>
712
+ {!whole && (
713
+ <button
714
+ type="button"
715
+ aria-label={labels.earlierYears}
716
+ disabled={prevBlocked}
717
+ onClick={() => moveTo(active - YEAR_PAGE, false)}
718
+ className={arrow}
719
+ >
720
+ <ChevronLeft className="size-4 rtl:-scale-x-100" />
721
+ </button>
722
+ )}
723
+ <div id={captionId} aria-live="polite" className="text-sm font-semibold tabular-nums text-[var(--text-primary)]">
724
+ {caption}
725
+ </div>
726
+ {!whole && (
727
+ <button
728
+ type="button"
729
+ aria-label={labels.laterYears}
730
+ disabled={nextBlocked}
731
+ onClick={() => moveTo(active + YEAR_PAGE, false)}
732
+ className={arrow}
733
+ >
734
+ <ChevronRight className="size-4 rtl:-scale-x-100" />
735
+ </button>
736
+ )}
737
+ </div>
738
+ <div ref={gridRef} role="grid" aria-labelledby={captionId} className="grid gap-1">
739
+ {Array.from({ length: Math.ceil(count / COLUMNS) }, (_, row) => (
740
+ <div key={row} role="row" className="grid grid-cols-3 gap-1">
741
+ {years.slice(row * COLUMNS, row * COLUMNS + COLUMNS).map((year) => {
742
+ const disabled = outOfBounds(year);
743
+ const selected = year === value;
744
+ const current = year === currentYear;
745
+ return (
746
+ <button
747
+ key={year}
748
+ type="button"
749
+ role="gridcell"
750
+ data-year={year}
751
+ tabIndex={year === active ? 0 : -1}
752
+ aria-selected={selected}
753
+ aria-disabled={disabled || undefined}
754
+ aria-current={current ? "date" : undefined}
755
+ onClick={() => {
756
+ if (disabled) return;
757
+ setActive(year);
758
+ onPick(String(year));
759
+ }}
760
+ onKeyDown={onCellKeyDown}
761
+ className={cn(
762
+ "rounded px-2 py-1.5 text-sm tabular-nums transition-colors",
763
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset",
764
+ selected && !disabled
765
+ ? "focus-visible:ring-[var(--brand-contrast)]"
766
+ : "focus-visible:ring-[var(--brand)]",
767
+ disabled
768
+ ? "cursor-not-allowed text-[var(--text-placeholder)]"
769
+ : selected
770
+ ? "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)]"
771
+ : "text-[var(--text-primary)] hover:bg-[var(--bg-hover)]",
772
+ current && !selected && !disabled && "ring-1 ring-inset ring-[var(--border-strong)]",
773
+ )}
774
+ >
775
+ {yearFormat.format(new Date(year, 0, 1))}
776
+ </button>
777
+ );
778
+ })}
779
+ </div>
780
+ ))}
781
+ </div>
782
+ </div>
783
+ );
784
+ }
785
+
525
786
  // ── Field ─────────────────────────────────────────────────────────────────
526
787
 
527
788
  /**
@@ -542,83 +803,140 @@ function upperFirst(text: string, locale: string | undefined): string {
542
803
  return text.charAt(0).toLocaleUpperCase(locale) + text.slice(1);
543
804
  }
544
805
 
545
- export function MonthPicker({
546
- value,
547
- onChange,
548
- locale: localeProp,
549
- min,
550
- max,
551
- label,
552
- placeholder,
553
- disabled,
554
- invalid,
555
- formatOptions,
556
- currentMonth,
557
- labels: labelsProp,
558
- className,
559
- triggerClassName,
560
- variant = "field",
561
- headingLevel = 2,
562
- showToday = true,
563
- ...rest
564
- }: MonthPickerProps) {
806
+ export function MonthPicker(props: MonthPickerProps) {
807
+ const {
808
+ value,
809
+ onChange,
810
+ locale: localeProp,
811
+ min,
812
+ max,
813
+ label,
814
+ placeholder,
815
+ disabled,
816
+ invalid,
817
+ error,
818
+ hint,
819
+ size = "md",
820
+ mode = "month",
821
+ formatOptions,
822
+ currentMonth,
823
+ labels: labelsProp,
824
+ className,
825
+ triggerClassName,
826
+ variant = "field",
827
+ headingLevel = 2,
828
+ showToday = true,
829
+ ...rest
830
+ } = props;
565
831
  const labels = useMonthPickerLabels(labelsProp);
566
832
  const locale = useKitLocale(localeProp);
567
- const selected = parseMonth(value);
833
+ const fromProvider = useKitDateFormatter();
834
+ const years = mode === "year";
835
+ const selected = years ? null : parseMonth(value);
836
+ const selectedYear = years ? parseYear(value) : null;
568
837
  const minKey = boundKey(min);
569
838
  const maxKey = boundKey(max);
839
+ const minYear = parseYear(min);
840
+ const maxYear = parseYear(max);
570
841
  const currentKey = boundKey(currentMonth) ?? monthKey(new Date());
842
+ const currentYear = parseYear(currentKey)!;
843
+ // The key the trigger shows, in the mode's own shape: "YYYY-MM" or "YYYY".
844
+ const shownKey = years ? (selectedYear !== null ? String(selectedYear) : null) : selected ? toKey(selected) : null;
571
845
 
572
- const triggerText = selected
573
- ? upperFirst(
574
- new Intl.DateTimeFormat(locale, formatOptions ?? DEFAULT_TRIGGER_FORMAT).format(
575
- firstOf(selected),
846
+ // The kit's order: `formatOptions` (this field's own Intl look), then the provider's
847
+ // `formatDate` (keksdose K12 — the app's one way of writing a month), then the default.
848
+ const triggerText = shownKey
849
+ ? (formatOptions === undefined &&
850
+ fromProvider?.(shownKey, { unit: years ? "year" : "month", source: "monthPicker", locale, weekday: false })) ||
851
+ upperFirst(
852
+ new Intl.DateTimeFormat(locale, formatOptions ?? (years ? YEAR_FORMAT : DEFAULT_TRIGGER_FORMAT)).format(
853
+ years ? new Date(selectedYear!, 0, 1) : firstOf(selected!),
576
854
  ),
577
855
  locale,
578
856
  )
579
857
  : (placeholder ?? "");
580
858
 
581
859
  const id = useId();
582
- const [aria, wrapperRest] = splitTriggerAria(rest);
860
+ // keksdose K4: the caption and the error, on the trigger after the caller's own
861
+ // description. Painted for `invalid` and a message — not for a bare `aria-invalid`,
862
+ // which this field has never painted for.
863
+ const messages = useFieldMessages({ hint, error, describedBy: rest["aria-describedby"] });
864
+ const [ownAria, wrapperRest] = splitTriggerAria(rest);
865
+ // The field's trigger is described by the caption and the error too; the stepper
866
+ // draws neither, so its heading keeps the caller's own description.
867
+ const aria: TriggerAria = { ...ownAria, "aria-describedby": messages.describedBy };
583
868
  const labelId = `${id}-label`;
584
869
  const valueId = `${id}-value`;
585
870
  const panelId = `${id}-panel`;
586
871
  // Only a STRING label can double as a name; a ReactNode may hold its own controls.
587
872
  const named = typeof label === "string";
873
+ // `sm` is the toolbar trigger, for an unlabelled picker only (see `size`).
874
+ const small = size === "sm" && label === undefined;
588
875
  // The panel is portalled to <body>, out of the subtree whose `dir` it inherited — an
589
876
  // RTL form got an LTR grid. Read when opening (`toggle` is the popover's only way
590
877
  // open) and put back on the panel. Also what the arrow keys above read.
591
878
  const rootRef = useRef<HTMLDivElement>(null);
592
879
  const [dir, setDir] = useState<Direction>("ltr");
880
+ const panelLabel = years ? labels.yearPanel : labels.panel;
593
881
 
594
- const grid = (close: () => void) => (
595
- <MonthGrid
596
- value={selected}
597
- locale={locale}
598
- minKey={minKey}
599
- maxKey={maxKey}
600
- currentKey={currentKey}
601
- labels={labels}
602
- onPick={(key) => {
603
- onChange(key);
604
- close();
605
- }}
606
- />
607
- );
882
+ const grid = (close: () => void) =>
883
+ years ? (
884
+ <YearGrid
885
+ value={selectedYear}
886
+ locale={locale}
887
+ minYear={minYear}
888
+ maxYear={maxYear}
889
+ currentYear={currentYear}
890
+ labels={labels}
891
+ onPick={(key) => {
892
+ onChange(key);
893
+ close();
894
+ }}
895
+ />
896
+ ) : (
897
+ <MonthGrid
898
+ value={selected}
899
+ locale={locale}
900
+ minKey={minKey}
901
+ maxKey={maxKey}
902
+ currentKey={currentKey}
903
+ labels={labels}
904
+ onPick={(key) => {
905
+ onChange(key);
906
+ close();
907
+ }}
908
+ />
909
+ );
608
910
 
609
911
  if (variant === "stepper") {
610
912
  // The arrows step from the value, or from "now" when there is none; each is dead
611
- // once the month it leads to is out of bounds, and Today once "now" is.
612
- const base = selected ?? parseMonth(currentKey)!;
613
- const prevKey = toKey(shiftMonth(base, -1));
614
- const nextKey = toKey(shiftMonth(base, 1));
615
- const outside = (key: string) => Boolean((minKey && key < minKey) || (maxKey && key > maxKey));
913
+ // once the month (or year) it leads to is out of bounds, and Today once "now" is.
914
+ let prevKey: string;
915
+ let nextKey: string;
916
+ let nowKey: string;
917
+ let outside: (key: string) => boolean;
918
+ if (years) {
919
+ const base = selectedYear ?? currentYear;
920
+ prevKey = String(base - 1);
921
+ nextKey = String(base + 1);
922
+ nowKey = String(currentYear);
923
+ outside = (key) => {
924
+ const y = Number(key);
925
+ return (minYear !== null && y < minYear) || (maxYear !== null && y > maxYear);
926
+ };
927
+ } else {
928
+ const base = selected ?? parseMonth(currentKey)!;
929
+ prevKey = toKey(shiftMonth(base, -1));
930
+ nextKey = toKey(shiftMonth(base, 1));
931
+ nowKey = currentKey;
932
+ outside = (key) => Boolean((minKey && key < minKey) || (maxKey && key > maxKey));
933
+ }
616
934
  const Heading = headingLevel ? (`h${headingLevel}` as const) : null;
617
935
  const trigger = (
618
936
  <Popover
619
937
  width={256}
620
938
  panelId={panelId}
621
- labels={{ panel: labels.panel }}
939
+ labels={{ panel: panelLabel }}
622
940
  dir={dir}
623
941
  trigger={({ open, toggle, ref }) => (
624
942
  <MonthHeadingTrigger
@@ -631,7 +949,7 @@ export function MonthPicker({
631
949
  text={triggerText}
632
950
  panelId={panelId}
633
951
  disabled={disabled}
634
- aria={aria}
952
+ aria={ownAria}
635
953
  />
636
954
  )}
637
955
  >
@@ -646,16 +964,16 @@ export function MonthPicker({
646
964
  <Button
647
965
  variant="secondary"
648
966
  size="sm"
649
- disabled={disabled || outside(currentKey)}
967
+ disabled={disabled || outside(nowKey)}
650
968
  onClick={() => {
651
- if (currentKey !== value) onChange(currentKey);
969
+ if (nowKey !== shownKey) onChange(nowKey);
652
970
  }}
653
971
  >
654
- {labels.today}
972
+ {years ? labels.thisYear : labels.today}
655
973
  </Button>
656
974
  )}
657
975
  <IconButton
658
- aria-label={labels.previousMonth}
976
+ aria-label={years ? labels.previousYear : labels.previousMonth}
659
977
  disabled={disabled || outside(prevKey)}
660
978
  onClick={() => onChange(prevKey)}
661
979
  >
@@ -663,7 +981,7 @@ export function MonthPicker({
663
981
  <ChevronLeft className="rtl:-scale-x-100" />
664
982
  </IconButton>
665
983
  <IconButton
666
- aria-label={labels.nextMonth}
984
+ aria-label={years ? labels.nextYear : labels.nextMonth}
667
985
  disabled={disabled || outside(nextKey)}
668
986
  onClick={() => onChange(nextKey)}
669
987
  >
@@ -674,9 +992,11 @@ export function MonthPicker({
674
992
  );
675
993
  }
676
994
 
677
- return (
678
- <div {...wrapperRest} ref={rootRef} className={cn("relative", className)}>
679
- {label !== undefined && <FieldLabel>{label}</FieldLabel>}
995
+ const field = (
996
+ // `w-fit` for the toolbar size: the calendar glyph is placed against this box, and a
997
+ // full-width box would park it at the far end of the row, away from its trigger.
998
+ <div {...wrapperRest} ref={rootRef} className={cn("relative", small && "w-fit max-w-full", className)}>
999
+ {label !== undefined && <FieldLabelLine label={label} hint={messages.labelHint} />}
680
1000
  {/* The hidden twin the trigger is named by — see DateField. `sr-only-fixed`,
681
1001
  and inside this `relative` root either way. */}
682
1002
  {named && (
@@ -687,7 +1007,7 @@ export function MonthPicker({
687
1007
  <Popover
688
1008
  width={256}
689
1009
  panelId={panelId}
690
- labels={{ panel: labels.panel }}
1010
+ labels={{ panel: panelLabel }}
691
1011
  dir={dir}
692
1012
  trigger={({ open, toggle, ref }) => (
693
1013
  <MonthFieldTrigger
@@ -698,7 +1018,7 @@ export function MonthPicker({
698
1018
  }}
699
1019
  triggerRef={ref}
700
1020
  triggerText={triggerText}
701
- hasValue={selected != null}
1021
+ hasValue={shownKey !== null}
702
1022
  // As DatePicker: a caller's reference first; an `id` without a label of our
703
1023
  // own references the trigger itself, so an external <label htmlFor> names it.
704
1024
  labelledBy={[
@@ -712,8 +1032,9 @@ export function MonthPicker({
712
1032
  valueId={valueId}
713
1033
  panelId={panelId}
714
1034
  padded={label !== undefined}
1035
+ small={small}
715
1036
  disabled={disabled}
716
- invalid={invalid}
1037
+ invalid={invalid || hasContent(error)}
717
1038
  className={triggerClassName}
718
1039
  />
719
1040
  )}
@@ -726,4 +1047,19 @@ export function MonthPicker({
726
1047
  />
727
1048
  </div>
728
1049
  );
1050
+ // An unlabelled picker has no label line: a FieldHint goes at its end, outside the
1051
+ // box, as on an unlabelled Select.
1052
+ const endHint = label === undefined ? messages.labelHint : undefined;
1053
+ return (
1054
+ <FieldBox below={messages.below} reserve={"error" in props || "hint" in props}>
1055
+ {endHint === undefined ? (
1056
+ field
1057
+ ) : (
1058
+ <div className="flex items-center gap-1.5">
1059
+ <div className={cn("min-w-0", !small && "flex-1")}>{field}</div>
1060
+ <span className="flex shrink-0 items-center">{endHint}</span>
1061
+ </div>
1062
+ )}
1063
+ </FieldBox>
1064
+ );
729
1065
  }