From 75e64001f3c1e795952b72c1db10c57a8df099d0 Mon Sep 17 00:00:00 2001 From: interacsean Date: Mon, 5 Oct 2026 14:07:11 +1100 Subject: [PATCH] fix(date-field): open DatePicker calendar on the typed/selected date The calendar's focused date was seeded once at mount and never followed the field, so a date typed into the segments opened the popover on today's month (and arrow + Enter selected relative to it). Re-land the roving focus on the current value each time the popover opens, falling back to today when empty. DateRangePicker already re-landed on the start date; it now also falls back to a typed end date, then today, instead of a stale last-viewed month. Refs tailor-inc/platform-planning#2001 Co-Authored-By: Claude Opus 5.5 --- .changeset/date-picker-open-on-value.md | 5 ++ .../components/date-field/date-field.test.tsx | 63 +++++++++++++++++++ .../src/components/date-field/date-field.tsx | 15 ++++- .../date-field/date-range-picker.test.tsx | 18 ++++++ .../date-field/date-range-picker.tsx | 11 +++- 5 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 .changeset/date-picker-open-on-value.md diff --git a/.changeset/date-picker-open-on-value.md b/.changeset/date-picker-open-on-value.md new file mode 100644 index 000000000..5773f32f9 --- /dev/null +++ b/.changeset/date-picker-open-on-value.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/app-shell": patch +--- + +`DatePicker` and `DateRangePicker` now open their calendar on the current field value. Previously a date typed into the segments (or set from outside while closed) was ignored, so the popover opened on today's month — or on whatever month was last viewed — and arrow-key + Enter selected relative to that. The calendar now focuses the selected/typed date on every open (the range start, else a typed end), falling back to today when empty. diff --git a/packages/core/src/components/date-field/date-field.test.tsx b/packages/core/src/components/date-field/date-field.test.tsx index e3e8df6fc..7f82f8c1e 100644 --- a/packages/core/src/components/date-field/date-field.test.tsx +++ b/packages/core/src/components/date-field/date-field.test.tsx @@ -33,6 +33,9 @@ function getCalendarCells() { return screen.getAllByRole("button", { hidden: true }).filter((c) => c.closest('[role="grid"]')); } +/** Accessible name of the focused element — the roving-focus day cell once the popover opens. */ +const focusedLabel = () => document.activeElement?.getAttribute("aria-label") ?? ""; + function getEnabledCalendarCells() { return getCalendarCells().filter( (c) => !c.hasAttribute("data-disabled") && !c.hasAttribute("data-outside-month"), @@ -1116,6 +1119,66 @@ describe("DatePicker keyboard", () => { }); }); + // Regression: the calendar's roving focus was seeded once at mount and never + // followed the field, so a typed date opened the popover on today's month. + it("opens the calendar on a date typed into an empty field (Alt+↓)", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole("spinbutton", { name: "month" })); + await user.keyboard("12202025"); + await waitFor(() => expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-12-20")); + + await user.keyboard("{Alt>}{ArrowDown}{/Alt}"); + await waitFor(() => { + expect(document.activeElement?.getAttribute("aria-label")).toMatch(/December 20, 2025/); + }); + + await user.keyboard("{ArrowRight}{Enter}"); + await waitFor(() => expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-12-21")); + }); + + it("re-lands the calendar on the current value each time it opens", async () => { + const user = userEvent.setup(); + function Controlled() { + const [value, setValue] = useState(new CalendarDate(2025, 6, 15)); + return ( + <> + + + + + ); + } + render(); + const trigger = () => screen.getByRole("button", { name: "Open calendar" }); + + // Page away, dismiss, change the value externally — reopening follows the value. + await user.click(trigger()); + await waitFor(() => expect(focusedLabel()).toMatch(/June 15, 2025/)); + await user.keyboard("{PageDown}{PageDown}{Escape}"); + await user.click(screen.getByRole("button", { name: "set" })); + await user.click(trigger()); + await waitFor(() => expect(focusedLabel()).toMatch(/February 10, 2024/)); + + // Cleared → falls back to today, not the last-viewed month. + await user.keyboard("{Escape}"); + await user.click(screen.getByRole("button", { name: "clear" })); + await user.click(trigger()); + const now = today(getLocalTimeZone()).toDate(getLocalTimeZone()); + const todayLabel = now.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); + await waitFor(() => expect(focusedLabel()).toContain(todayLabel)); + }); + it("does not blur when focus moves from the field into the popup", async () => { const user = userEvent.setup(); const onBlur = vi.fn(); diff --git a/packages/core/src/components/date-field/date-field.tsx b/packages/core/src/components/date-field/date-field.tsx index 351958931..660e53144 100644 --- a/packages/core/src/components/date-field/date-field.tsx +++ b/packages/core/src/components/date-field/date-field.tsx @@ -8,7 +8,7 @@ import { type ReactElement, type Ref, } from "react"; -import type { DateValue } from "@internationalized/date"; +import { toCalendarDate, today, type DateValue } from "@internationalized/date"; import { useFieldRootContext } from "@base-ui/react/internals/field-root-context"; import { cn } from "@/lib/utils"; import { useResolvedLocale, useTimeZone } from "@/contexts/appshell-context"; @@ -287,6 +287,15 @@ const DatePicker = forwardRef(function DatePicker { + setOpen(nextOpen); + // The calendar's focus outlives the popover, so re-land it on the current + // (possibly typed) value each time it opens — today when empty (APG). + if (nextOpen) { + calState.setFocusedDate(val != null ? toCalendarDate(val as never) : today(resolvedTz)); + } + }; + const localValidationMessage = useMemo(() => { const key = invalidMessageKey(fieldState.invalidReason); return key ? t(key) : undefined; @@ -360,7 +369,7 @@ const DatePicker = forwardRef(function DatePicker setOpen(true)} + onOpenCalendar={() => handleOpenChange(true)} isDisabled={bindings.isDisabled} isReadOnly={bindings.isReadOnly} isInvalid={bindings.isInvalid} diff --git a/packages/core/src/components/date-field/date-range-picker.test.tsx b/packages/core/src/components/date-field/date-range-picker.test.tsx index fd6e6a3a2..b46310882 100644 --- a/packages/core/src/components/date-field/date-range-picker.test.tsx +++ b/packages/core/src/components/date-field/date-range-picker.test.tsx @@ -37,6 +37,9 @@ async function typeDate(user: ReturnType, field: Field, await user.keyboard(year); } +/** Accessible name of the focused element — the roving-focus day cell once the popover opens. */ +const focusedLabel = () => document.activeElement?.getAttribute("aria-label") ?? ""; + const normalizeIds = (html: string) => html.replace(/id="base-ui-[^"]+"/g, 'id="base-ui-ID"'); // ─── Snapshot ───────────────────────────────────────────────────────────────── @@ -448,6 +451,21 @@ describe("DateRangePicker popover", () => { expect(await screen.findByRole("dialog")).toBeDefined(); }); + it("opens on a typed end date when only the end is set, not a stale month", async () => { + const user = userEvent.setup(); + render(); + + // Page away from today and dismiss — the next open must not reuse that month. + await user.click(screen.getByRole("button", { name: "Open calendar" })); + await screen.findByRole("dialog"); + await user.keyboard("{Shift>}{PageUp}{/Shift}{Escape}"); + + await typeDate(user, "end date", "2025-12-20"); + seg("end date", "year").focus(); + await user.keyboard("{Alt>}{ArrowDown}{/Alt}"); + await waitFor(() => expect(focusedLabel()).toMatch(/December 20, 2025/)); + }); + it("drops a dangling anchor when the popover is dismissed mid-selection", async () => { const user = userEvent.setup(); const onChange = vi.fn(); diff --git a/packages/core/src/components/date-field/date-range-picker.tsx b/packages/core/src/components/date-field/date-range-picker.tsx index 548c1f569..3daf965a1 100644 --- a/packages/core/src/components/date-field/date-range-picker.tsx +++ b/packages/core/src/components/date-field/date-range-picker.tsx @@ -9,7 +9,7 @@ import { type ReactElement, type Ref, } from "react"; -import { toCalendarDate, type DateValue } from "@internationalized/date"; +import { toCalendarDate, today, type DateValue } from "@internationalized/date"; import { cn } from "@/lib/utils"; import { useFieldRootContext } from "@base-ui/react/internals/field-root-context"; import { useResolvedLocale, useTimeZone } from "@/contexts/appshell-context"; @@ -266,8 +266,13 @@ const DateRangePicker = forwardRef(function DateRangePicker { setOpen(nextOpen); if (nextOpen) { - // Land the roving focus on the range start (react-aria's autofocus target). - if (startVal != null) calendarState.setFocusedDate(toCalendarDate(startVal as never)); + // Land the roving focus on the range start (react-aria's autofocus target), + // else whichever end is typed, else today — never a stale month from a + // previous open. + const target = startVal ?? endVal; + calendarState.setFocusedDate( + target != null ? toCalendarDate(target as never) : today(resolvedTz), + ); } else { // Dismissed mid-selection — drop the dangling anchor. calendarState.cancelSelection();