Keep calendar timeline stable when inspecting dates
This commit is contained in:
@@ -44,11 +44,18 @@ export function CalendarHeatmap({
|
|||||||
const latestDate =
|
const latestDate =
|
||||||
allDays.findLast((day) => day.state !== "future")?.date ??
|
allDays.findLast((day) => day.state !== "future")?.date ??
|
||||||
allDays.at(-1)?.date;
|
allDays.at(-1)?.date;
|
||||||
// A date selected in the editor must stay visible even outside the current window.
|
// Inspecting a square changes selection, not the window. Only an external
|
||||||
const anchor =
|
// date outside the visible preset needs a new anchor to bring it into view.
|
||||||
controlledDate && allDays.some((day) => day.date === controlledDate)
|
const [windowAnchor, setWindowAnchor] = useState({ latestDate, date: latestDate });
|
||||||
? controlledDate
|
let anchor = windowAnchor.latestDate === latestDate ? windowAnchor.date : latestDate;
|
||||||
: 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 [localSelectedDate, setLocalSelectedDate] = useState(anchor);
|
||||||
const selectedDate = controlledDate ?? localSelectedDate;
|
const selectedDate = controlledDate ?? localSelectedDate;
|
||||||
function setSelectedDate(date: string) {
|
function setSelectedDate(date: string) {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ test("calendar associates exact keyboard guidance and preserves focus when selec
|
|||||||
expect(selected.tabIndex).toBe(0);
|
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() {
|
function Calendar() {
|
||||||
const [date, setDate] = useState("2026-09-01");
|
const [date, setDate] = useState("2026-09-01");
|
||||||
return <CalendarHeatmap days={demoCalendar(3, 8)} unit="glasses" label="Water history" selectedDate={date} onSelectDate={setDate} />;
|
return <CalendarHeatmap days={demoCalendar(3, 8)} unit="glasses" label="Water history" selectedDate={date} onSelectDate={setDate} />;
|
||||||
@@ -105,6 +105,55 @@ test("rejected calendar clicks and repeated arrows do not leave stale focus requ
|
|||||||
expect(rejected.getAttribute("aria-pressed")).toBe("true");
|
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<string>();
|
||||||
|
return <CalendarHeatmap days={days} unit="pages" label="Reading history" selectedDate={date} onSelectDate={setDate} />;
|
||||||
|
}
|
||||||
|
await act(async () => root.render(<Calendar />));
|
||||||
|
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<HTMLButtonElement>(`.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<HTMLButtonElement>('.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 <><input aria-label="Editor date" /><CalendarHeatmap days={demoCalendar(3, 8)} unit="pages" label="Reading history" selectedDate={date} onSelectDate={update} /></>;
|
||||||
|
}
|
||||||
|
await act(async () => root.render(<Calendar />));
|
||||||
|
await act(async () => button("3m").click());
|
||||||
|
const editor = container.querySelector<HTMLInputElement>('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 () => {
|
test("editor controls expose their explanatory hints", async () => {
|
||||||
await act(async () => root.render(<EditingWorkbench />));
|
await act(async () => root.render(<EditingWorkbench />));
|
||||||
expect(description(container.querySelector("input[readonly]")!)).toContain("Method is fixed");
|
expect(description(container.querySelector("input[readonly]")!)).toContain("Method is fixed");
|
||||||
|
|||||||
Reference in New Issue
Block a user