From b925b167374ca4f992ca3a6472145e7f579b6883 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 04:12:44 +0100 Subject: [PATCH] fix: make the calendar usable from the keyboard: one tab stop and arrow keys in the month grid, focus into the event card and back, reachable tasks and import drop zone --- .../calendar/__tests__/calendar-app.test.tsx | 40 +++++++++++++++++++ .../__tests__/calendar-dialogs.test.tsx | 7 ++++ components/calendar/calendar-app.tsx | 1 + components/calendar/calendar-month-view.tsx | 36 ++++++++++++++++- components/calendar/event-detail-popover.tsx | 17 ++++++++ components/calendar/ical-import-modal.tsx | 16 +++++--- components/calendar/task-list-view.tsx | 18 +++++---- 7 files changed, 121 insertions(+), 14 deletions(-) diff --git a/components/calendar/__tests__/calendar-app.test.tsx b/components/calendar/__tests__/calendar-app.test.tsx index 416977604..c4a982ca7 100644 --- a/components/calendar/__tests__/calendar-app.test.tsx +++ b/components/calendar/__tests__/calendar-app.test.tsx @@ -494,3 +494,43 @@ describe('CalendarApp event menu', () => { expect(await screen.findByPlaceholderText('form.title')).toHaveValue('Standup'); }); }); + +describe('CalendarApp from the keyboard', () => { + it('gives the month grid one tab stop and moves between days with the arrows', () => { + setUp([], 'month'); + const { container } = renderApp(); + const tabbable = container.querySelectorAll('[data-day-button][tabindex="0"]'); + expect(tabbable).toHaveLength(1); + const today = tabbable[0] as HTMLButtonElement; + today.focus(); + fireEvent.keyDown(today, { key: 'ArrowRight' }); + expect(useCalendarStore.getState().selectedDate).toEqual(new Date(2026, 9, 13)); + expect(document.activeElement).toBe(container.querySelector('[data-day-button="2026-10-13"]')); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }); + expect(useCalendarStore.getState().selectedDate).toEqual(new Date(2026, 9, 20)); + // The arrows moved the selection, not the calendar a month on. + expect(useCalendarStore.getState().viewMode).toBe('month'); + }); + + it('moves the focus into an opened event card and back to the event on Escape', async () => { + renderApp(); + const card = screen.getAllByRole('button', { name: /Standup/ })[0]; + card.focus(); + fireEvent.click(card, { detail: 0 }); + const popover = await screen.findByRole('dialog', { name: 'Standup' }); + await waitFor(() => expect(popover.contains(document.activeElement)).toBe(true)); + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Standup' })).toBeNull()); + expect(document.activeElement).toBe(card); + }); + + it('reaches a task with Tab and opens it', async () => { + setUp([], 'tasks'); + const task = { id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { cal: true }, progress: 'needs-action', priority: 0, due: null, showWithoutTime: true } as never; + useTaskStore.setState({ tasks: [task] } as never); + client.getCalendarTasks.mockResolvedValue([task]); + renderApp(); + fireEvent.click(await screen.findByRole('button', { name: /Buy milk/ })); + expect(screen.getByRole('heading', { name: 'tasks.edit' })).toBeInTheDocument(); + }); +}); diff --git a/components/calendar/__tests__/calendar-dialogs.test.tsx b/components/calendar/__tests__/calendar-dialogs.test.tsx index 9ec311ce1..eef65314a 100644 --- a/components/calendar/__tests__/calendar-dialogs.test.tsx +++ b/components/calendar/__tests__/calendar-dialogs.test.tsx @@ -55,3 +55,10 @@ describe('calendar dialogs', () => { expect(onClose).not.toHaveBeenCalled(); }); }); + +describe('import dialog from the keyboard', () => { + it('lets the keyboard reach the file drop zone', () => { + render(); + expect(screen.getByRole('button', { name: /select_file/ })).toBeInTheDocument(); + }); +}); diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 053508b2b..2e631f464 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -2238,6 +2238,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = isSubscriptionCalendar={isSubscriptionCalendar} timeFormat={timeFormat} isMobile={isMobile} + pinned={detailPinned} /> )} diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx index c9e8c8298..f7957b450 100644 --- a/components/calendar/calendar-month-view.tsx +++ b/components/calendar/calendar-month-view.tsx @@ -274,6 +274,38 @@ export function CalendarMonthView({ anchorSelector: "[data-week]", }); + // One tab stop for the whole grid (the selected day's date), arrows move + // from day to day and week to week, Enter opens the day (R8): Tab used to + // walk every date of the loaded months. + const selectedKey = dayKey(selectedDate); + const rovingKey = useMemo(() => { + const keys = new Set(weeks.flat().map(dayKey)); + if (keys.has(selectedKey)) return selectedKey; + const focusKey = dayKey(focus.date); + return keys.has(focusKey) ? focusKey : (weeks[0]?.[0] ? dayKey(weeks[0][0]) : selectedKey); + }, [weeks, selectedKey, focus.date]); + const handleDayKeyDown = useCallback((e: React.KeyboardEvent, day: Date, dayIndex: number) => { + if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; + const rtl = getComputedStyle(e.currentTarget).direction === "rtl"; + let delta: number; + switch (e.key) { + case "ArrowLeft": delta = rtl ? 1 : -1; break; + case "ArrowRight": delta = rtl ? -1 : 1; break; + case "ArrowUp": delta = -7; break; + case "ArrowDown": delta = 7; break; + case "Home": delta = -dayIndex; break; + case "End": delta = 6 - dayIndex; break; + default: return; + } + // The grid's arrows, not the calendar's previous/next month. + e.preventDefault(); + const next = addDays(day, delta); + const target = scrollRef.current?.querySelector(`[data-day-button="${dayKey(next)}"]`); + if (!target) return; + onSelectDate(next); + target.focus(); + }, [onSelectDate]); + const [dropDayKey, setDropDayKey] = useState(null); const handleCellDragOver = useCallback((e: DragEvent, dayKey: string) => { @@ -402,7 +434,9 @@ export function CalendarMonthView({ whole cell already does. */} - + )} {importMode === "url" && ( diff --git a/components/calendar/task-list-view.tsx b/components/calendar/task-list-view.tsx index 370029ea3..a00d12278 100644 --- a/components/calendar/task-list-view.tsx +++ b/components/calendar/task-list-view.tsx @@ -222,9 +222,13 @@ export function TaskListView({ {isCompleted && } - {/* Content */} -
-
+ {/* Content: a button, so Tab reaches the task and Enter opens it. */} +
+ -
+ {dueDateInfo && ( @@ -247,8 +251,8 @@ export function TaskListView({ {cal.name} )} -
-
+ + ); })}