diff --git a/src/components/design-system/CalendarHeatmap.tsx b/src/components/design-system/CalendarHeatmap.tsx index 173176c..831ffee 100644 --- a/src/components/design-system/CalendarHeatmap.tsx +++ b/src/components/design-system/CalendarHeatmap.tsx @@ -44,11 +44,18 @@ export function CalendarHeatmap({ const latestDate = allDays.findLast((day) => day.state !== "future")?.date ?? allDays.at(-1)?.date; - // A date selected in the editor must stay visible even outside the current window. - const anchor = - controlledDate && allDays.some((day) => day.date === controlledDate) - ? controlledDate - : latestDate; + // Inspecting a square changes selection, not the window. Only an external + // date outside the visible preset needs a new anchor to bring it into view. + const [windowAnchor, setWindowAnchor] = useState({ latestDate, date: latestDate }); + let anchor = windowAnchor.latestDate === latestDate ? windowAnchor.date : latestDate; + if (months !== "custom" && anchor && controlledDate && + allDays.some(day => day.date === controlledDate) && + !visibleCalendarDays(allDays, months, anchor).some(day => day.date === controlledDate)) { + anchor = controlledDate; + } + if (windowAnchor.latestDate !== latestDate || windowAnchor.date !== anchor) { + setWindowAnchor({ latestDate, date: anchor }); + } const [localSelectedDate, setLocalSelectedDate] = useState(anchor); const selectedDate = controlledDate ?? localSelectedDate; function setSelectedDate(date: string) { diff --git a/src/components/design-system/accessibility.test.tsx b/src/components/design-system/accessibility.test.tsx index 92042f3..c8e5008 100644 --- a/src/components/design-system/accessibility.test.tsx +++ b/src/components/design-system/accessibility.test.tsx @@ -66,7 +66,7 @@ test("calendar associates exact keyboard guidance and preserves focus when selec expect(selected.tabIndex).toBe(0); }); -test("calendar focuses the accepted date after a controlled month window changes", async () => { +test("calendar focuses the accepted date when selection crosses a month boundary", async () => { function Calendar() { const [date, setDate] = useState("2026-09-01"); return ; @@ -105,6 +105,55 @@ test("rejected calendar clicks and repeated arrows do not leave stale focus requ expect(rejected.getAttribute("aria-pressed")).toBe("true"); }); +test("selecting tracked, empty, unscheduled and future dates never changes the displayed timeline", async () => { + const days = demoCalendar(3, 8).map((day, index) => ({ ...day, + state: day.state === "future" ? day.state : index % 2 === 0 ? "not-due" as const : "due" as const, + value: index % 3 === 0 ? 0 : day.value, + })); + function Calendar() { + const [date, setDate] = useState(); + return ; + } + await act(async () => root.render()); + const timeline = () => ({ + dates: [...container.querySelectorAll(".ds-day")].map(day => day.getAttribute("aria-label")?.slice(0, 10)), + months: [...container.querySelectorAll(".ds-calendar-month-label")].map(month => month.textContent), + range: container.querySelector(".ds-calendar-range-label")?.textContent, + weeks: (container.querySelector(".ds-calendar") as HTMLElement).style.getPropertyValue("--calendar-weeks"), + }); + for (const preset of [12, 6, 4, 3, "Custom"]) { + await act(async () => button(typeof preset === "number" ? `${preset}m` : preset).click()); + const before = timeline(); + for (const index of [1, 0, 8, 7, before.dates.length - 1]) { + const date = before.dates[index]!; + await act(async () => container.querySelector(`.ds-day[aria-label^="${date}:"]`)!.click()); + expect(timeline()).toEqual(before); + expect(container.querySelector('.ds-day[aria-pressed="true"]')?.getAttribute("aria-label")).toStartWith(`${date}:`); + } + for (const key of ["ArrowLeft", "Home", "End"]) { + const selected = container.querySelector('.ds-day[aria-pressed="true"]')!; + await act(async () => selected.dispatchEvent(new dom.KeyboardEvent("keydown", { key, bubbles: true }) as unknown as KeyboardEvent)); + expect(timeline()).toEqual(before); + } + } +}); + +test("an external editor date outside the window remains reachable without moving focus", async () => { + let setDate!: (date: string) => void; + function Calendar() { + const [date, update] = useState("2026-09-04"); + setDate = update; + return <>; + } + await act(async () => root.render()); + await act(async () => button("3m").click()); + const editor = container.querySelector('input[aria-label="Editor date"]')!; + editor.focus(); + await act(async () => setDate("2026-03-04")); + expect(container.querySelector('.ds-day[aria-pressed="true"]')?.getAttribute("aria-label")).toStartWith("2026-03-04:"); + expect(document.activeElement).toBe(editor); +}); + test("editor controls expose their explanatory hints", async () => { await act(async () => root.render()); expect(description(container.querySelector("input[readonly]")!)).toContain("Method is fixed");