Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/date-picker-open-on-value.md
Original file line number Diff line number Diff line change
@@ -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.
63 changes: 63 additions & 0 deletions packages/core/src/components/date-field/date-field.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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(<DatePicker aria-label="Date" onChange={onChange} />);

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<CalendarDate | null>(new CalendarDate(2025, 6, 15));
return (
<>
<DatePicker aria-label="Date" value={value} onChange={setValue} />
<button type="button" onClick={() => setValue(new CalendarDate(2024, 2, 10))}>
set
</button>
<button type="button" onClick={() => setValue(null)}>
clear
</button>
</>
);
}
render(<Controlled />);
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();
Expand Down
15 changes: 12 additions & 3 deletions packages/core/src/components/date-field/date-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -287,6 +287,15 @@ const DatePicker = forwardRef(function DatePicker<T extends DateValue = DateValu
timeZone: resolvedTz,
});

const handleOpenChange = (nextOpen: boolean) => {
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;
Expand Down Expand Up @@ -360,7 +369,7 @@ const DatePicker = forwardRef(function DatePicker<T extends DateValue = DateValu
/>
<DatePopover
open={open}
onOpenChange={setOpen}
onOpenChange={handleOpenChange}
ariaLabel={popoverAriaLabel}
ariaLabelledby={bindings.labelledBy}
popupRef={popupRef}
Expand All @@ -376,7 +385,7 @@ const DatePicker = forwardRef(function DatePicker<T extends DateValue = DateValu
applyShortcut={fieldState.applyShortcut}
commitOnBlur={fieldState.commitOnBlur}
expandShortYear={fieldState.expandShortYear}
onOpenCalendar={() => setOpen(true)}
onOpenCalendar={() => handleOpenChange(true)}
isDisabled={bindings.isDisabled}
isReadOnly={bindings.isReadOnly}
isInvalid={bindings.isInvalid}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ async function typeDate(user: ReturnType<typeof userEvent.setup>, 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 ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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(<DateRangePicker aria-label="Billing period" />);

// 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();
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/components/date-field/date-range-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -266,8 +266,13 @@ const DateRangePicker = forwardRef(function DateRangePicker<T extends DateValue
const handleOpenChange = (nextOpen: boolean) => {
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();
Expand Down
Loading