@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.
- package/README.md +42 -25
- package/dist/chart.d.ts +8 -7
- package/dist/components/amount-input.d.ts +34 -7
- package/dist/components/amount-input.js +14 -10
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/autocomplete.d.ts +26 -0
- package/dist/components/autocomplete.js +164 -137
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/button-group.d.ts +8 -7
- package/dist/components/calculator.d.ts +8 -7
- package/dist/components/column-mapper.d.ts +235 -0
- package/dist/components/column-mapper.js +411 -0
- package/dist/components/column-mapper.js.map +1 -0
- package/dist/components/combobox-core.d.ts +39 -2
- package/dist/components/combobox-core.js +29 -6
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +113 -3
- package/dist/components/combobox.js +456 -313
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +8 -7
- package/dist/components/confirm-dialog.js +7 -8
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.d.ts +5 -4
- package/dist/components/country-select.d.ts +50 -7
- package/dist/components/country-select.js +65 -9
- package/dist/components/country-select.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +8 -7
- package/dist/components/danger-confirm.js +64 -7
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/date-picker.d.ts +41 -0
- package/dist/components/date-picker.js +269 -8
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/entity-combobox.d.ts +48 -2
- package/dist/components/entity-combobox.js +159 -114
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/facing-pair.d.ts +8 -7
- package/dist/components/field-parts.d.ts +116 -6
- package/dist/components/field-parts.js +84 -0
- package/dist/components/field-parts.js.map +1 -1
- package/dist/components/field-strip.d.ts +105 -0
- package/dist/components/field-strip.js +46 -0
- package/dist/components/field-strip.js.map +1 -0
- package/dist/components/file-button.d.ts +8 -7
- package/dist/components/file-dropzone.d.ts +8 -7
- package/dist/components/form-actions.d.ts +8 -7
- package/dist/components/form-actions.js +23 -3
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/iban-input.d.ts +8 -7
- package/dist/components/icon-picker.d.ts +10 -1
- package/dist/components/icon-picker.js +12 -4
- package/dist/components/icon-picker.js.map +1 -1
- package/dist/components/language-select.d.ts +8 -7
- package/dist/components/money-field.d.ts +11 -7
- package/dist/components/money-field.js.map +1 -1
- package/dist/components/month-picker.js +2 -1
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +31 -2
- package/dist/components/multi-entity-combobox.js +152 -103
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/number-field.d.ts +8 -7
- package/dist/components/number-input.d.ts +8 -7
- package/dist/components/numpad-sheet.d.ts +8 -7
- package/dist/components/phone-input.d.ts +8 -7
- package/dist/components/reauth-dialog.d.ts +2 -2
- package/dist/components/reauth-dialog.js +6 -8
- package/dist/components/reauth-dialog.js.map +1 -1
- package/dist/components/series-chart.d.ts +8 -7
- package/dist/components/settings-fields.d.ts +8 -7
- package/dist/components/share-card.d.ts +8 -7
- package/dist/components/swatch-picker.d.ts +17 -1
- package/dist/components/swatch-picker.js +10 -4
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/text-link.d.ts +8 -7
- package/dist/components/tile-radio.d.ts +20 -0
- package/dist/components/tile-radio.js +25 -8
- package/dist/components/tile-radio.js.map +1 -1
- package/dist/components/time-input.d.ts +8 -7
- package/dist/components/toggle-group.js +2 -2
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/trigger-aria.d.ts +3 -0
- package/dist/components/trigger-aria.js +3 -1
- package/dist/components/trigger-aria.js.map +1 -1
- package/dist/components/ui.d.ts +5 -4
- package/dist/components/ui.js +60 -28
- package/dist/components/ui.js.map +1 -1
- package/dist/feedback/feedback-attachment.d.ts +66 -1
- package/dist/feedback/feedback-attachment.js +185 -65
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-dialog.d.ts +66 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.d.ts +67 -252
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-thread.d.ts +66 -287
- package/dist/feedback/feedback-thread.js +16 -3
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/{kit-labels-R8mIAc9N.d.ts → feedback-DOwPu-Il.d.ts} +1273 -30
- package/dist/feedback.d.ts +65 -3
- package/dist/hooks/use-file-drop.d.ts +8 -7
- package/dist/i18n/defaults.d.ts +8 -7
- package/dist/i18n/defaults.js +3 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +8 -7
- package/dist/i18n/german.js +36 -2
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +5 -4
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +8 -7
- package/dist/i18n/locales/de-CH.d.ts +8 -7
- package/dist/i18n/locales/en.d.ts +8 -7
- package/dist/i18n/locales/en.js +7 -0
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +8 -7
- package/dist/i18n/locales/es.js +37 -2
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +8 -7
- package/dist/i18n/locales/fr.js +36 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +8 -7
- package/dist/i18n/locales/hu.js +38 -2
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +8 -7
- package/dist/i18n/locales/it.js +37 -2
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +8 -7
- package/dist/i18n/locales/zh.js +34 -2
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +8 -7
- package/dist/i18n/review.js +20 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/column-mapping.d.ts +81 -0
- package/dist/lib/column-mapping.js +108 -0
- package/dist/lib/column-mapping.js.map +1 -0
- package/dist/lib/strip-fade.d.ts +2 -2
- package/dist/lib/strip-fade.js.map +1 -1
- package/dist/lib/table-text.d.ts +109 -1
- package/dist/lib/table-text.js +122 -1
- package/dist/lib/table-text.js.map +1 -1
- package/dist/rhf/fields.d.ts +142 -13
- package/dist/rhf/fields.js +185 -3
- package/dist/rhf/fields.js.map +1 -1
- package/dist/rhf/form.d.ts +8 -7
- package/dist/rhf.d.ts +9 -8
- package/dist/rhf.js.map +1 -1
- package/dist/shell/app-shell.d.ts +8 -7
- package/dist/shell/top-bar-brand.d.ts +8 -7
- package/dist/shell/topbar-action-menu.d.ts +36 -3
- package/dist/shell/topbar-action-menu.js +74 -33
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell.d.ts +8 -7
- package/dist/table-text.d.ts +1 -1
- package/dist/wizard/stepper-nav.d.ts +38 -9
- package/dist/wizard/stepper-nav.js +4 -0
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard.d.ts +8 -7
- package/package.json +1 -1
- package/src/components/amount-input.tsx +50 -14
- package/src/components/autocomplete.tsx +188 -116
- package/src/components/column-mapper.tsx +768 -0
- package/src/components/combobox-core.tsx +82 -9
- package/src/components/combobox.tsx +655 -335
- package/src/components/confirm-dialog.tsx +12 -8
- package/src/components/country-select.tsx +135 -22
- package/src/components/danger-confirm.tsx +274 -24
- package/src/components/date-picker.tsx +423 -10
- package/src/components/entity-combobox.tsx +243 -129
- package/src/components/field-parts.tsx +224 -5
- package/src/components/field-strip.tsx +149 -0
- package/src/components/form-actions.tsx +52 -5
- package/src/components/icon-picker.tsx +23 -4
- package/src/components/money-field.tsx +3 -0
- package/src/components/month-picker.tsx +2 -1
- package/src/components/multi-entity-combobox.tsx +221 -121
- package/src/components/reauth-dialog.tsx +17 -18
- package/src/components/swatch-picker.tsx +29 -5
- package/src/components/tile-radio.tsx +74 -13
- package/src/components/toggle-group.tsx +2 -2
- package/src/components/trigger-aria.ts +5 -0
- package/src/components/ui.tsx +114 -33
- package/src/feedback/feedback-attachment.tsx +372 -72
- package/src/feedback/feedback-dialog.tsx +7 -3
- package/src/feedback/feedback-inbox.tsx +3 -2
- package/src/feedback/feedback-thread.tsx +112 -9
- package/src/i18n/defaults.ts +2 -0
- package/src/i18n/german.ts +42 -0
- package/src/i18n/kit-labels.tsx +4 -0
- package/src/i18n/locales/en.ts +27 -5
- package/src/i18n/locales/es.ts +41 -0
- package/src/i18n/locales/fr.ts +42 -0
- package/src/i18n/locales/hu.ts +37 -0
- package/src/i18n/locales/it.ts +41 -0
- package/src/i18n/locales/zh.ts +32 -0
- package/src/i18n/review.ts +19 -0
- package/src/index.ts +18 -0
- package/src/lib/column-mapping.ts +234 -0
- package/src/lib/strip-fade.ts +2 -2
- package/src/lib/table-text.ts +271 -0
- package/src/rhf/fields.tsx +421 -6
- package/src/rhf.ts +2 -1
- package/src/shell/topbar-action-menu.tsx +134 -38
- package/src/wizard/stepper-nav.tsx +35 -1
- package/dist/components/field-anatomy.d.ts +0 -95
- package/dist/components/field-anatomy.js +0 -84
- package/dist/components/field-anatomy.js.map +0 -1
- package/dist/feedback-attachment-NhUm7Zga.d.ts +0 -367
- 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-
|
|
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
|
-
<
|
|
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<
|
|
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
|
-
<
|
|
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
|
/>
|