@eifi1/ui-kit 0.22.0 → 0.24.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 (208) hide show
  1. package/README.md +42 -25
  2. package/dist/chart.d.ts +8 -7
  3. package/dist/components/amount-input.d.ts +34 -7
  4. package/dist/components/amount-input.js +14 -10
  5. package/dist/components/amount-input.js.map +1 -1
  6. package/dist/components/autocomplete.d.ts +26 -0
  7. package/dist/components/autocomplete.js +164 -137
  8. package/dist/components/autocomplete.js.map +1 -1
  9. package/dist/components/button-group.d.ts +8 -7
  10. package/dist/components/calculator.d.ts +8 -7
  11. package/dist/components/column-mapper.d.ts +235 -0
  12. package/dist/components/column-mapper.js +411 -0
  13. package/dist/components/column-mapper.js.map +1 -0
  14. package/dist/components/combobox-core.d.ts +39 -2
  15. package/dist/components/combobox-core.js +29 -6
  16. package/dist/components/combobox-core.js.map +1 -1
  17. package/dist/components/combobox.d.ts +113 -3
  18. package/dist/components/combobox.js +456 -313
  19. package/dist/components/combobox.js.map +1 -1
  20. package/dist/components/confirm-dialog.d.ts +8 -7
  21. package/dist/components/confirm-dialog.js +7 -8
  22. package/dist/components/confirm-dialog.js.map +1 -1
  23. package/dist/components/copy-button.d.ts +5 -4
  24. package/dist/components/country-select.d.ts +50 -7
  25. package/dist/components/country-select.js +65 -9
  26. package/dist/components/country-select.js.map +1 -1
  27. package/dist/components/danger-confirm.d.ts +8 -7
  28. package/dist/components/danger-confirm.js +64 -7
  29. package/dist/components/danger-confirm.js.map +1 -1
  30. package/dist/components/date-picker.d.ts +41 -0
  31. package/dist/components/date-picker.js +269 -8
  32. package/dist/components/date-picker.js.map +1 -1
  33. package/dist/components/entity-combobox.d.ts +48 -2
  34. package/dist/components/entity-combobox.js +159 -114
  35. package/dist/components/entity-combobox.js.map +1 -1
  36. package/dist/components/facing-pair.d.ts +8 -7
  37. package/dist/components/field-parts.d.ts +116 -6
  38. package/dist/components/field-parts.js +84 -0
  39. package/dist/components/field-parts.js.map +1 -1
  40. package/dist/components/field-strip.d.ts +105 -0
  41. package/dist/components/field-strip.js +46 -0
  42. package/dist/components/field-strip.js.map +1 -0
  43. package/dist/components/file-button.d.ts +8 -7
  44. package/dist/components/file-dropzone.d.ts +8 -7
  45. package/dist/components/form-actions.d.ts +8 -7
  46. package/dist/components/form-actions.js +23 -3
  47. package/dist/components/form-actions.js.map +1 -1
  48. package/dist/components/iban-input.d.ts +8 -7
  49. package/dist/components/icon-picker.d.ts +10 -1
  50. package/dist/components/icon-picker.js +12 -4
  51. package/dist/components/icon-picker.js.map +1 -1
  52. package/dist/components/language-select.d.ts +8 -7
  53. package/dist/components/money-field.d.ts +11 -7
  54. package/dist/components/money-field.js.map +1 -1
  55. package/dist/components/month-picker.js +2 -1
  56. package/dist/components/month-picker.js.map +1 -1
  57. package/dist/components/multi-entity-combobox.d.ts +31 -2
  58. package/dist/components/multi-entity-combobox.js +152 -103
  59. package/dist/components/multi-entity-combobox.js.map +1 -1
  60. package/dist/components/number-field.d.ts +8 -7
  61. package/dist/components/number-input.d.ts +8 -7
  62. package/dist/components/numpad-sheet.d.ts +8 -7
  63. package/dist/components/phone-input.d.ts +8 -7
  64. package/dist/components/reauth-dialog.d.ts +2 -2
  65. package/dist/components/reauth-dialog.js +6 -8
  66. package/dist/components/reauth-dialog.js.map +1 -1
  67. package/dist/components/series-chart.d.ts +8 -7
  68. package/dist/components/settings-fields.d.ts +8 -7
  69. package/dist/components/share-card.d.ts +8 -7
  70. package/dist/components/swatch-picker.d.ts +17 -1
  71. package/dist/components/swatch-picker.js +10 -4
  72. package/dist/components/swatch-picker.js.map +1 -1
  73. package/dist/components/text-link.d.ts +8 -7
  74. package/dist/components/tile-radio.d.ts +20 -0
  75. package/dist/components/tile-radio.js +25 -8
  76. package/dist/components/tile-radio.js.map +1 -1
  77. package/dist/components/time-input.d.ts +8 -7
  78. package/dist/components/toggle-group.js +2 -2
  79. package/dist/components/toggle-group.js.map +1 -1
  80. package/dist/components/trigger-aria.d.ts +3 -0
  81. package/dist/components/trigger-aria.js +3 -1
  82. package/dist/components/trigger-aria.js.map +1 -1
  83. package/dist/components/ui.d.ts +5 -4
  84. package/dist/components/ui.js +60 -28
  85. package/dist/components/ui.js.map +1 -1
  86. package/dist/feedback/feedback-attachment.d.ts +66 -1
  87. package/dist/feedback/feedback-attachment.js +185 -65
  88. package/dist/feedback/feedback-attachment.js.map +1 -1
  89. package/dist/feedback/feedback-dialog.d.ts +66 -1
  90. package/dist/feedback/feedback-dialog.js.map +1 -1
  91. package/dist/feedback/feedback-inbox.d.ts +67 -252
  92. package/dist/feedback/feedback-inbox.js.map +1 -1
  93. package/dist/feedback/feedback-thread.d.ts +66 -287
  94. package/dist/feedback/feedback-thread.js +16 -3
  95. package/dist/feedback/feedback-thread.js.map +1 -1
  96. package/dist/{kit-labels-R8mIAc9N.d.ts → feedback-DOwPu-Il.d.ts} +1273 -30
  97. package/dist/feedback.d.ts +65 -3
  98. package/dist/hooks/use-file-drop.d.ts +8 -7
  99. package/dist/i18n/defaults.d.ts +8 -7
  100. package/dist/i18n/defaults.js +3 -1
  101. package/dist/i18n/defaults.js.map +1 -1
  102. package/dist/i18n/german.d.ts +8 -7
  103. package/dist/i18n/german.js +36 -2
  104. package/dist/i18n/german.js.map +1 -1
  105. package/dist/i18n/kit-labels.d.ts +5 -4
  106. package/dist/i18n/kit-labels.js.map +1 -1
  107. package/dist/i18n/languages.d.ts +8 -7
  108. package/dist/i18n/locales/de-CH.d.ts +8 -7
  109. package/dist/i18n/locales/en.d.ts +8 -7
  110. package/dist/i18n/locales/en.js +7 -0
  111. package/dist/i18n/locales/en.js.map +1 -1
  112. package/dist/i18n/locales/es.d.ts +8 -7
  113. package/dist/i18n/locales/es.js +37 -2
  114. package/dist/i18n/locales/es.js.map +1 -1
  115. package/dist/i18n/locales/fr.d.ts +8 -7
  116. package/dist/i18n/locales/fr.js +36 -2
  117. package/dist/i18n/locales/fr.js.map +1 -1
  118. package/dist/i18n/locales/hu.d.ts +8 -7
  119. package/dist/i18n/locales/hu.js +38 -2
  120. package/dist/i18n/locales/hu.js.map +1 -1
  121. package/dist/i18n/locales/it.d.ts +8 -7
  122. package/dist/i18n/locales/it.js +37 -2
  123. package/dist/i18n/locales/it.js.map +1 -1
  124. package/dist/i18n/locales/zh.d.ts +8 -7
  125. package/dist/i18n/locales/zh.js +34 -2
  126. package/dist/i18n/locales/zh.js.map +1 -1
  127. package/dist/i18n/review.d.ts +8 -7
  128. package/dist/i18n/review.js +20 -1
  129. package/dist/i18n/review.js.map +1 -1
  130. package/dist/index.d.ts +6 -4
  131. package/dist/index.js +16 -0
  132. package/dist/index.js.map +1 -1
  133. package/dist/lib/column-mapping.d.ts +81 -0
  134. package/dist/lib/column-mapping.js +108 -0
  135. package/dist/lib/column-mapping.js.map +1 -0
  136. package/dist/lib/strip-fade.d.ts +2 -2
  137. package/dist/lib/strip-fade.js.map +1 -1
  138. package/dist/lib/table-text.d.ts +109 -1
  139. package/dist/lib/table-text.js +122 -1
  140. package/dist/lib/table-text.js.map +1 -1
  141. package/dist/rhf/fields.d.ts +142 -13
  142. package/dist/rhf/fields.js +185 -3
  143. package/dist/rhf/fields.js.map +1 -1
  144. package/dist/rhf/form.d.ts +8 -7
  145. package/dist/rhf.d.ts +9 -8
  146. package/dist/rhf.js.map +1 -1
  147. package/dist/shell/app-shell.d.ts +8 -7
  148. package/dist/shell/top-bar-brand.d.ts +8 -7
  149. package/dist/shell/topbar-action-menu.d.ts +36 -3
  150. package/dist/shell/topbar-action-menu.js +74 -33
  151. package/dist/shell/topbar-action-menu.js.map +1 -1
  152. package/dist/shell.d.ts +8 -7
  153. package/dist/table-text.d.ts +1 -1
  154. package/dist/wizard/stepper-nav.d.ts +38 -9
  155. package/dist/wizard/stepper-nav.js +4 -0
  156. package/dist/wizard/stepper-nav.js.map +1 -1
  157. package/dist/wizard.d.ts +8 -7
  158. package/package.json +1 -1
  159. package/src/components/amount-input.tsx +50 -14
  160. package/src/components/autocomplete.tsx +188 -116
  161. package/src/components/column-mapper.tsx +768 -0
  162. package/src/components/combobox-core.tsx +82 -9
  163. package/src/components/combobox.tsx +655 -335
  164. package/src/components/confirm-dialog.tsx +12 -8
  165. package/src/components/country-select.tsx +135 -22
  166. package/src/components/danger-confirm.tsx +274 -24
  167. package/src/components/date-picker.tsx +423 -10
  168. package/src/components/entity-combobox.tsx +243 -129
  169. package/src/components/field-parts.tsx +224 -5
  170. package/src/components/field-strip.tsx +149 -0
  171. package/src/components/form-actions.tsx +52 -5
  172. package/src/components/icon-picker.tsx +23 -4
  173. package/src/components/money-field.tsx +3 -0
  174. package/src/components/month-picker.tsx +2 -1
  175. package/src/components/multi-entity-combobox.tsx +221 -121
  176. package/src/components/reauth-dialog.tsx +17 -18
  177. package/src/components/swatch-picker.tsx +29 -5
  178. package/src/components/tile-radio.tsx +74 -13
  179. package/src/components/toggle-group.tsx +2 -2
  180. package/src/components/trigger-aria.ts +5 -0
  181. package/src/components/ui.tsx +114 -33
  182. package/src/feedback/feedback-attachment.tsx +372 -72
  183. package/src/feedback/feedback-dialog.tsx +7 -3
  184. package/src/feedback/feedback-inbox.tsx +3 -2
  185. package/src/feedback/feedback-thread.tsx +112 -9
  186. package/src/i18n/defaults.ts +2 -0
  187. package/src/i18n/german.ts +42 -0
  188. package/src/i18n/kit-labels.tsx +4 -0
  189. package/src/i18n/locales/en.ts +27 -5
  190. package/src/i18n/locales/es.ts +41 -0
  191. package/src/i18n/locales/fr.ts +42 -0
  192. package/src/i18n/locales/hu.ts +37 -0
  193. package/src/i18n/locales/it.ts +41 -0
  194. package/src/i18n/locales/zh.ts +32 -0
  195. package/src/i18n/review.ts +19 -0
  196. package/src/index.ts +18 -0
  197. package/src/lib/column-mapping.ts +234 -0
  198. package/src/lib/strip-fade.ts +2 -2
  199. package/src/lib/table-text.ts +271 -0
  200. package/src/rhf/fields.tsx +421 -6
  201. package/src/rhf.ts +2 -1
  202. package/src/shell/topbar-action-menu.tsx +134 -38
  203. package/src/wizard/stepper-nav.tsx +35 -1
  204. package/dist/components/field-anatomy.d.ts +0 -95
  205. package/dist/components/field-anatomy.js +0 -84
  206. package/dist/components/field-anatomy.js.map +0 -1
  207. package/dist/feedback-attachment-NhUm7Zga.d.ts +0 -367
  208. package/src/components/field-anatomy.tsx +0 -190
@@ -1,9 +1,9 @@
1
1
  import { useEffect, useId, useMemo, useRef, useState } from "react";
2
- import type { ComponentPropsWithoutRef, ComponentType, ReactNode, RefObject } from "react";
3
- import { Calendar, CalendarClock, ChevronLeft, ChevronRight, X } from "lucide-react";
2
+ import type { ComponentPropsWithoutRef, ComponentType, KeyboardEvent, ReactNode, RefObject } from "react";
3
+ import { Calendar, CalendarClock, ChevronDown, ChevronLeft, ChevronRight, X } from "lucide-react";
4
4
  import { cn } from "../lib/cn";
5
- import { dirOf, type Direction } from "../lib/direction";
6
- import { addDaysIso, parseIsoDate } from "../lib/dates";
5
+ import { dirOf, horizontalStep, type Direction } from "../lib/direction";
6
+ import { addDaysIso, monthKey, pad, parseIsoDate } from "../lib/dates";
7
7
  import {
8
8
  DEFAULT_DATE_PICKER_LABELS,
9
9
  DEFAULT_PICKER_SHEET_LABELS,
@@ -16,10 +16,11 @@ import {
16
16
  import { splitTriggerAria } from "./trigger-aria";
17
17
  import type { TriggerAria } from "./trigger-aria";
18
18
  import { Button, FIELD_BASE, FIELD_TRIGGER, FIELD_FLOATING_PAD, FIELD_INVALID, PHONE_QUERY } from "./ui";
19
- import { FieldBox, FieldLabelLine, useFieldMessages } from "./field-anatomy";
19
+ import { FieldBox, FieldLabelLine, useFieldMessages } from "./field-parts";
20
20
  import { FullBleedDialog } from "./full-bleed-dialog";
21
21
  import { useMediaQuery } from "../hooks/use-media-query";
22
- import { MiniCalendar, type MiniCalendarProps } from "./mini-calendar";
22
+ import { DEFAULT_MINI_CALENDAR_LABELS, MiniCalendar, type MiniCalendarProps } from "./mini-calendar";
23
+ import { DEFAULT_MONTH_PICKER_LABELS } from "./month-picker";
23
24
  import { Popover } from "./popover";
24
25
  import { Tooltip } from "./tooltip";
25
26
 
@@ -83,6 +84,47 @@ interface DatePickerBaseProps extends Omit<ComponentPropsWithoutRef<"div">, "onC
83
84
  * ships English defaults, which a translating host has to be able to replace.
84
85
  * Usually unnecessary now: the calendar reads `miniCalendar` from the provider. */
85
86
  calendarLabels?: MiniCalendarProps["labels"];
87
+ /**
88
+ * Make the panel's caption ("July 2026") a button that opens the year's twelve
89
+ * months, with ‹ › stepping a year; picking a month returns to its days. Far dates
90
+ * stop being a march of month arrows.
91
+ *
92
+ * kastlan is why: its service-charge period runs July to June, and with one month on
93
+ * screen and ‹ › the only way across, the end of the period was eleven clicks past
94
+ * the start — so kastlan kept two date fields instead of `RhfDateRangePicker`. With
95
+ * the jump the whole period is open, caption, Jul, 1, caption, next year, Jun, 30.
96
+ *
97
+ * What it adds, for the keyboard and a screen reader:
98
+ * - the caption is a disclosure button (`aria-expanded`). Its name is the month it
99
+ * shows, so speech input can say what it sees; it is `aria-live`, as the
100
+ * calendar's caption was, so paging months still says where you landed; and it is
101
+ * described by `monthPicker.panel` ("Choose a month").
102
+ * - the month grid is `MonthPicker`'s grid: one tab stop, arrows walk the months
103
+ * (across the year's edge), Home/End the row, PageUp/PageDown a year, Enter or a
104
+ * click picks. Focus opens on the month on show and comes back on its days — the
105
+ * selected day if it is in that month, else the 1st. Months outside `min`/`max`
106
+ * are `aria-disabled`, and a year arrow is once the whole year beyond is.
107
+ * - Escape in the month grid goes back to the days without closing the panel (focus
108
+ * on the caption); in the days, Escape closes the panel as before.
109
+ *
110
+ * No new strings: the month arrows keep `miniCalendar.previousMonth` / `nextMonth`
111
+ * (`calendarLabels`), and the month grid speaks with `MonthPicker`'s words —
112
+ * `monthPicker.previousYear` / `nextYear` / `month` / `panel` — because it is that
113
+ * grid, and a host that translated it once should not have to again.
114
+ *
115
+ * Off by default: the caption becomes one more stop in the panel's tab order, which a
116
+ * 0.x minor does not spring on a form. Works in the popover and in
117
+ * `DateRangePicker`'s phone sheet alike, beside presets and in `commit="apply"`
118
+ * (a preset picked while the months are open goes back to the days, on its start).
119
+ *
120
+ * Why not a two-month panel (`months={2}`) instead, or beside it: for a twelve-month
121
+ * period it still pages six times where this takes one year step, and two
122
+ * `MiniCalendar`s side by side each follow the value on their own — picking the end
123
+ * in the right-hand month would pull the left one onto it — so the calendar would
124
+ * have to learn to share a month first. Not worth that for a saving of one arrow on a
125
+ * range across a single month boundary.
126
+ */
127
+ monthJump?: boolean;
86
128
  }
87
129
 
88
130
  /** Format an ISO date for the trigger. `lib/dates`' `formatIsoDate` insists on a
@@ -561,6 +603,374 @@ function StepButton({
561
603
  );
562
604
  }
563
605
 
606
+ // ── Month jump (`monthJump`, kastlan) ─────────────────────────────────────
607
+
608
+ /** A "YYYY-MM" key as a count of months, and back: month arithmetic with no `Date`
609
+ * rollover to step around. */
610
+ const monthIndex = (key: string) => Number(key.slice(0, 4)) * 12 + Number(key.slice(5, 7)) - 1;
611
+ const keyOfIndex = (index: number) =>
612
+ `${String(Math.floor(index / 12)).padStart(4, "0")}-${pad((index % 12) + 1)}`;
613
+ const shiftKey = (key: string, delta: number) => keyOfIndex(monthIndex(key) + delta);
614
+ /** The 1st of a "YYYY-MM" month, for `Intl` to name. */
615
+ const firstOfKey = (key: string) => new Date(Number(key.slice(0, 4)), Number(key.slice(5, 7)) - 1, 1);
616
+
617
+ /** Upper-case the first letter only, as `MonthPicker` does: "julio de 2026" heads a
618
+ * panel as "Julio de 2026" — not "Julio De 2026", which CSS `capitalize` would write. */
619
+ const upperFirst = (text: string, locale: string | undefined) =>
620
+ text.charAt(0).toLocaleUpperCase(locale) + text.slice(1);
621
+
622
+ /** Months per row of the month grid — `MonthPicker`'s 4×3. */
623
+ const MONTH_COLUMNS = 3;
624
+
625
+ /**
626
+ * The month `MiniCalendar` opens on — the selection, or today, pulled inside the
627
+ * bounds — worked out the way it does, so the caption drawn over a calendar that was
628
+ * given no `month` names the month that calendar is showing.
629
+ */
630
+ function openingMonth(from: string, to: string, lo: Date | null, hi: Date | null): string {
631
+ const start = parseIsoDate(from) ?? parseIsoDate(to) ?? new Date();
632
+ if (lo && start < lo) return monthKey(lo);
633
+ if (hi && start > hi) return monthKey(hi);
634
+ return monthKey(start);
635
+ }
636
+
637
+ type PanelCalendarProps = Pick<
638
+ MiniCalendarProps,
639
+ "mode" | "from" | "to" | "locale" | "min" | "max" | "labels" | "onSelect" | "focusOnOpen"
640
+ > & {
641
+ /** See {@link DatePickerBaseProps.monthJump}. */
642
+ monthJump?: boolean;
643
+ };
644
+
645
+ /** The panel's calendar: the plain {@link MiniCalendar}, exactly as before — or, with
646
+ * `monthJump`, the same calendar under a caption that opens a month grid. */
647
+ function PanelCalendar({ monthJump, ...calendar }: PanelCalendarProps) {
648
+ return monthJump ? <JumpCalendar {...calendar} /> : <MiniCalendar {...calendar} />;
649
+ }
650
+
651
+ /** How the day grid is mounted: `key` remounts it, `month` is the month it follows
652
+ * (left out until the panel first moves, so it opens exactly where it always has), and
653
+ * `focus` is its `focusOnOpen`. */
654
+ interface DayGridMount {
655
+ key: number;
656
+ month?: string;
657
+ focus: boolean;
658
+ }
659
+
660
+ /**
661
+ * {@link MiniCalendar} with its navigation drawn here — ‹ caption › — so the caption can
662
+ * be a button that swaps the days for a month grid ({@link DatePickerBaseProps.monthJump}).
663
+ *
664
+ * Built on the calendar's own `hideNavigation` / `month` / `onMonthChange`, not a fork
665
+ * of it: the days, their keys, the range band and every announcement stay the
666
+ * calendar's. What this keeps is `shown`, the month on screen, for the caption — fed by
667
+ * the arrows here, by the calendar's `onMonthChange` (PageUp, arrowing off the edge),
668
+ * and by the value, whose changes the calendar follows WITHOUT reporting them (a preset
669
+ * in `commit="apply"` moves the grid to its start). That last one is mirrored below with
670
+ * the calendar's own rule, and when it lands in another month the day grid is remounted
671
+ * there rather than steered by `month`: a `month` that changes in the same render as the
672
+ * value would win over the value's day, and the grid's tab stop would land on the
673
+ * month's same-numbered day instead of the preset's start.
674
+ *
675
+ * The month grid is `MonthPicker`'s, redrawn here because that one is internal to its
676
+ * file; same roles, keys and words (`monthPicker` labels).
677
+ */
678
+ function JumpCalendar({
679
+ from = "",
680
+ to = "",
681
+ mode = "range",
682
+ locale,
683
+ min,
684
+ max,
685
+ labels,
686
+ onSelect,
687
+ focusOnOpen,
688
+ }: Omit<PanelCalendarProps, "monthJump">) {
689
+ // The month arrows say what they said when the calendar drew them; the month grid
690
+ // says what `MonthPicker`'s says.
691
+ const calendarText = useKitLabels("miniCalendar", DEFAULT_MINI_CALENDAR_LABELS, labels);
692
+ const monthText = useKitLabels("monthPicker", DEFAULT_MONTH_PICKER_LABELS);
693
+ const id = useId();
694
+ const captionId = `${id}-caption`;
695
+ const hintId = `${id}-hint`;
696
+ const captionRef = useRef<HTMLButtonElement>(null);
697
+ const monthsRef = useRef<HTMLDivElement>(null);
698
+
699
+ const lo = min ? parseIsoDate(min) : null;
700
+ const hi = max ? parseIsoDate(max) : null;
701
+ const minKey = lo ? monthKey(lo) : null;
702
+ const maxKey = hi ? monthKey(hi) : null;
703
+ const outOfBounds = (key: string) => Boolean((minKey && key < minKey) || (maxKey && key > maxKey));
704
+ const clampKey = (key: string) => (minKey && key < minKey ? minKey : maxKey && key > maxKey ? maxKey : key);
705
+
706
+ const [view, setView] = useState<"days" | "months">("days");
707
+ const [shown, setShown] = useState(() => openingMonth(from, to, lo, hi));
708
+ const [dayGrid, setDayGrid] = useState<DayGridMount>(() => ({ key: 0, focus: Boolean(focusOnOpen) }));
709
+ // The month holding the month grid's tab stop — and with it the year on show.
710
+ const [active, setActive] = useState(shown);
711
+
712
+ // The value moved from outside the day grid (a preset, a reset): follow the end that
713
+ // changed, by the calendar's own rule, and remount the days there — see above. Also
714
+ // what takes an open month grid back to the days when a preset is picked beside it.
715
+ const [lastValue, setLastValue] = useState({ from, to });
716
+ if (lastValue.from !== from || lastValue.to !== to) {
717
+ const changed = lastValue.from !== from ? from : to;
718
+ setLastValue({ from, to });
719
+ const next = parseIsoDate(changed) ?? parseIsoDate(from) ?? parseIsoDate(to);
720
+ const key = next ? monthKey(next) : null;
721
+ if (key && (key !== shown || view === "months")) {
722
+ setShown(key);
723
+ setView("days");
724
+ setDayGrid((d) => ({ key: d.key + 1, month: key, focus: false }));
725
+ }
726
+ }
727
+
728
+ // A month cell that has to take focus once it exists: opening the grid, and arrowing
729
+ // across a year's edge, which relabels the same twelve buttons.
730
+ const pendingFocus = useRef<string | null>(null);
731
+ useEffect(() => {
732
+ const key = pendingFocus.current;
733
+ if (!key) return;
734
+ pendingFocus.current = null;
735
+ monthsRef.current?.querySelector<HTMLElement>(`[data-month="${key}"]`)?.focus();
736
+ });
737
+
738
+ const showMonth = (key: string) => {
739
+ setShown(key);
740
+ // Follows by `month`: the calendar keeps the day of the month (clamped), and the
741
+ // focus stays on the arrow being pressed.
742
+ setDayGrid((d) => ({ ...d, month: key }));
743
+ };
744
+ const openMonths = () => {
745
+ const start = clampKey(shown);
746
+ pendingFocus.current = start;
747
+ setActive(start);
748
+ setView("months");
749
+ };
750
+ const closeMonths = (focusCaption: boolean) => {
751
+ setView("days");
752
+ setDayGrid((d) => ({ ...d, month: shown, focus: false }));
753
+ if (focusCaption) captionRef.current?.focus();
754
+ };
755
+ const pick = (key: string) => {
756
+ setShown(key);
757
+ setView("days");
758
+ // Picked to go there: the focus goes with it, onto the days.
759
+ setDayGrid((d) => ({ key: d.key + 1, month: key, focus: true }));
760
+ };
761
+ const moveActive = (key: string, focus: boolean) => {
762
+ if (focus) pendingFocus.current = key;
763
+ setActive(key);
764
+ };
765
+
766
+ const inDays = view === "days";
767
+ // Escape in the month grid is "back", not "close": the month grid is a step INSIDE the
768
+ // panel, and losing the half-made range to a key meant to undo that step would be the
769
+ // worse surprise. Stopped here, so neither the popover nor the phone sheet sees it.
770
+ const escapeToDays = (e: KeyboardEvent<HTMLElement>) => {
771
+ if (e.key !== "Escape" || inDays) return;
772
+ e.preventDefault();
773
+ e.stopPropagation();
774
+ closeMonths(true);
775
+ };
776
+
777
+ const onMonthKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
778
+ if (e.key === "Escape") return escapeToDays(e);
779
+ // Left and right are visual: the grid runs the other way in a right-to-left panel.
780
+ const step = horizontalStep(e.key, e.currentTarget);
781
+ const col = (monthIndex(active) % 12) % MONTH_COLUMNS;
782
+ let delta: number;
783
+ switch (step ? "horizontal" : e.key) {
784
+ case "horizontal":
785
+ delta = step;
786
+ break;
787
+ case "ArrowUp":
788
+ delta = -MONTH_COLUMNS;
789
+ break;
790
+ case "ArrowDown":
791
+ delta = MONTH_COLUMNS;
792
+ break;
793
+ case "Home":
794
+ delta = -col;
795
+ break;
796
+ case "End":
797
+ delta = MONTH_COLUMNS - 1 - col;
798
+ break;
799
+ case "PageUp":
800
+ delta = -12;
801
+ break;
802
+ case "PageDown":
803
+ delta = 12;
804
+ break;
805
+ default:
806
+ return;
807
+ }
808
+ // Before the no-op check: these keys also scroll the panel.
809
+ e.preventDefault();
810
+ if (delta !== 0) moveActive(shiftKey(active, delta), true);
811
+ };
812
+
813
+ const year = Number(active.slice(0, 4));
814
+ // A year arrow is dead once every month of the year it leads to is out of bounds.
815
+ const prevYearBlocked = Boolean(minKey && year - 1 < Number(minKey.slice(0, 4)));
816
+ const nextYearBlocked = Boolean(maxKey && year + 1 > Number(maxKey.slice(0, 4)));
817
+ const prevBlocked = !inDays && prevYearBlocked;
818
+ const nextBlocked = !inDays && nextYearBlocked;
819
+
820
+ const longName = useMemo(() => new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }), [locale]);
821
+ const shortNames = useMemo(() => {
822
+ const fmt = new Intl.DateTimeFormat(locale, { month: "short" });
823
+ return Array.from({ length: 12 }, (_, i) => upperFirst(fmt.format(new Date(2000, i, 1)), locale));
824
+ }, [locale]);
825
+ const caption = inDays
826
+ ? upperFirst(longName.format(firstOfKey(shown)), locale)
827
+ : new Intl.DateTimeFormat(locale, { year: "numeric" }).format(new Date(year, 0, 1));
828
+
829
+ // The selection, as months: both ends, and the months between (a range's band).
830
+ const fromDate = parseIsoDate(from);
831
+ const toDate = parseIsoDate(to);
832
+ const fromKey = fromDate ? monthKey(fromDate) : null;
833
+ const toKey = toDate ? monthKey(toDate) : null;
834
+ const low = fromKey ?? toKey;
835
+ const high = toKey ?? fromKey;
836
+ const currentKey = monthKey(new Date());
837
+
838
+ // Aria-disabled rather than disabled, like the day cells: a focused button that
839
+ // turns `disabled` drops the focus to <body>, mid-panel.
840
+ const arrow =
841
+ "rounded p-1 text-[var(--text-muted)] hover:bg-[var(--bg-hover)] aria-disabled:cursor-not-allowed aria-disabled:opacity-30 aria-disabled:hover:bg-transparent";
842
+
843
+ return (
844
+ <div className="select-none">
845
+ {/* The calendar's own row, redrawn: the same arrows in the same places. Each
846
+ button stays mounted across the two views and changes what it steps, so a
847
+ press never pulls the element out from under the pointer or the focus. */}
848
+ <div className="flex items-center justify-between gap-1 pb-1">
849
+ <button
850
+ type="button"
851
+ aria-label={inDays ? calendarText.previousMonth : monthText.previousYear}
852
+ aria-disabled={prevBlocked || undefined}
853
+ onClick={() => {
854
+ if (inDays) showMonth(shiftKey(shown, -1));
855
+ else if (!prevBlocked) moveActive(shiftKey(active, -12), false);
856
+ }}
857
+ onKeyDown={escapeToDays}
858
+ className={arrow}
859
+ >
860
+ {/* Mirrored in RTL: "previous" points to the reading START, which is right. */}
861
+ <ChevronLeft className="size-4 rtl:-scale-x-100" aria-hidden />
862
+ </button>
863
+ <button
864
+ ref={captionRef}
865
+ type="button"
866
+ aria-expanded={!inDays}
867
+ aria-describedby={inDays ? hintId : undefined}
868
+ onClick={inDays ? openMonths : () => closeMonths(false)}
869
+ onKeyDown={escapeToDays}
870
+ className="inline-flex min-w-0 items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
871
+ >
872
+ {/* The name is the text, so it says what is on screen; `aria-live` because
873
+ pressing an arrow leaves focus ON the arrow, and this is the only thing
874
+ that says where it went. It also names the month grid. */}
875
+ <span id={captionId} aria-live="polite" className="truncate">
876
+ {caption}
877
+ </span>
878
+ <ChevronDown
879
+ aria-hidden
880
+ className={cn("size-3.5 shrink-0 text-[var(--text-muted)]", !inDays && "rotate-180")}
881
+ />
882
+ </button>
883
+ <button
884
+ type="button"
885
+ aria-label={inDays ? calendarText.nextMonth : monthText.nextYear}
886
+ aria-disabled={nextBlocked || undefined}
887
+ onClick={() => {
888
+ if (inDays) showMonth(shiftKey(shown, 1));
889
+ else if (!nextBlocked) moveActive(shiftKey(active, 12), false);
890
+ }}
891
+ onKeyDown={escapeToDays}
892
+ className={arrow}
893
+ >
894
+ <ChevronRight className="size-4 rtl:-scale-x-100" aria-hidden />
895
+ </button>
896
+ </div>
897
+ {/* The caption's description — read through the reference, never on its own. */}
898
+ <span id={hintId} hidden>
899
+ {monthText.panel}
900
+ </span>
901
+ {inDays ? (
902
+ <MiniCalendar
903
+ key={dayGrid.key}
904
+ mode={mode}
905
+ from={from}
906
+ to={to}
907
+ locale={locale}
908
+ min={min}
909
+ max={max}
910
+ labels={labels}
911
+ onSelect={onSelect}
912
+ focusOnOpen={dayGrid.focus}
913
+ hideNavigation
914
+ month={dayGrid.month}
915
+ onMonthChange={showMonth}
916
+ />
917
+ ) : (
918
+ <div ref={monthsRef} role="grid" aria-labelledby={captionId} className="grid gap-1">
919
+ {Array.from({ length: 12 / MONTH_COLUMNS }, (_, row) => (
920
+ <div key={row} role="row" className="grid grid-cols-3 gap-1">
921
+ {Array.from({ length: MONTH_COLUMNS }, (_, c) => {
922
+ const index = row * MONTH_COLUMNS + c;
923
+ const key = keyOfIndex(year * 12 + index);
924
+ const disabled = outOfBounds(key);
925
+ const selected = low !== null && high !== null && key >= low && key <= high;
926
+ const end = key === fromKey || key === toKey;
927
+ const current = key === currentKey;
928
+ return (
929
+ <button
930
+ key={c}
931
+ type="button"
932
+ role="gridcell"
933
+ data-month={key}
934
+ tabIndex={key === active ? 0 : -1}
935
+ aria-label={monthText.month(longName.format(firstOfKey(key)))}
936
+ aria-selected={selected}
937
+ aria-disabled={disabled || undefined}
938
+ aria-current={current ? "date" : undefined}
939
+ onClick={() => {
940
+ // The guard `disabled` would have been: out-of-bounds months stay
941
+ // focusable, so the roving tab stop never falls into a hole.
942
+ if (!disabled) pick(key);
943
+ }}
944
+ onKeyDown={onMonthKeyDown}
945
+ className={cn(
946
+ // 44px rows: the touch-target size, and four of them stand as tall
947
+ // as a month of days, so the panel does not jump as the views swap.
948
+ "h-11 rounded px-1 text-sm tabular-nums transition-colors",
949
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset",
950
+ // A --brand ring on a --brand fill exists only in the DOM.
951
+ end && !disabled ? "focus-visible:ring-[var(--brand-contrast)]" : "focus-visible:ring-[var(--brand)]",
952
+ disabled
953
+ ? "cursor-not-allowed text-[var(--text-placeholder)]"
954
+ : end
955
+ ? "bg-[var(--brand)] text-[var(--brand-contrast)] hover:bg-[var(--brand-hover)]"
956
+ : selected
957
+ ? "bg-[var(--brand-bg)] font-medium text-[var(--brand-muted)] hover:bg-[var(--brand-bg-hover)]"
958
+ : "text-[var(--text-primary)] hover:bg-[var(--bg-hover)]",
959
+ current && !selected && !disabled && "ring-1 ring-inset ring-[var(--border-strong)]",
960
+ )}
961
+ >
962
+ {shortNames[index]}
963
+ </button>
964
+ );
965
+ })}
966
+ </div>
967
+ ))}
968
+ </div>
969
+ )}
970
+ </div>
971
+ );
972
+ }
973
+
564
974
  export interface DatePickerProps extends DatePickerBaseProps {
565
975
  /** ISO "YYYY-MM-DD", or "" for empty. */
566
976
  value: string;
@@ -649,6 +1059,7 @@ export function DatePicker(props: DatePickerProps) {
649
1059
  todayLabel,
650
1060
  className,
651
1061
  calendarLabels,
1062
+ monthJump,
652
1063
  label,
653
1064
  clearable,
654
1065
  clearLabel,
@@ -699,7 +1110,8 @@ export function DatePicker(props: DatePickerProps) {
699
1110
  onClear={() => onChange("")}
700
1111
  >
701
1112
  {(close) => (
702
- <MiniCalendar
1113
+ <PanelCalendar
1114
+ monthJump={monthJump}
703
1115
  mode="single"
704
1116
  focusOnOpen
705
1117
  from={value}
@@ -1033,7 +1445,7 @@ function RangePanel({
1033
1445
  frame: PanelFrame;
1034
1446
  sheet: boolean;
1035
1447
  commitRange: (from: string, to: string, presetId: string | undefined) => void;
1036
- calendarProps: Pick<MiniCalendarProps, "locale" | "min" | "max" | "labels">;
1448
+ calendarProps: Pick<PanelCalendarProps, "locale" | "min" | "max" | "labels" | "monthJump">;
1037
1449
  labels: DatePickerLabels;
1038
1450
  renderDraftSummary?: DateRangePickerProps["renderDraftSummary"];
1039
1451
  }) {
@@ -1056,7 +1468,7 @@ function RangePanel({
1056
1468
  : null;
1057
1469
 
1058
1470
  const picker = (
1059
- <MiniCalendar
1471
+ <PanelCalendar
1060
1472
  {...calendarProps}
1061
1473
  focusOnOpen
1062
1474
  from={shown.from}
@@ -1165,6 +1577,7 @@ export function DateRangePicker(props: DateRangePickerProps) {
1165
1577
  sheetBackCloses,
1166
1578
  renderDraftSummary,
1167
1579
  calendarLabels,
1580
+ monthJump,
1168
1581
  label,
1169
1582
  clearable,
1170
1583
  clearLabel,
@@ -1249,7 +1662,7 @@ export function DateRangePicker(props: DateRangePickerProps) {
1249
1662
  frame={frame}
1250
1663
  sheet={sheet}
1251
1664
  commitRange={commitRange}
1252
- calendarProps={{ locale, min, max, labels: calendarLabels }}
1665
+ calendarProps={{ locale, min, max, labels: calendarLabels, monthJump }}
1253
1666
  labels={text}
1254
1667
  renderDraftSummary={renderDraftSummary}
1255
1668
  />