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. */} handleDayKeyDown(e, day, dayIndex)} onClick={(e) => { if (isMobile || !onOpenDay) return; e.stopPropagation(); diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index a29b8c058..4f7d563bb 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -47,6 +47,11 @@ interface EventDetailPopoverProps { isSubscriptionCalendar?: (calendarId: string) => boolean; timeFormat?: "12h" | "24h"; isMobile?: boolean; + /** + * Opened by a click or Enter rather than shown on hover: it takes the + * focus, and gives it back to the event when it closes. + */ + pinned?: boolean; } const POPOVER_WIDTH = 360; @@ -125,6 +130,7 @@ export function EventDetailPopover({ isSubscriptionCalendar, timeFormat = "24h", isMobile, + pinned = false, }: EventDetailPopoverProps) { const t = useTranslations("calendar"); const locale = useLocale(); @@ -244,6 +250,17 @@ export function EventDetailPopover({ return () => observer.disconnect(); }, [anchorRect, isMobile]); + useEffect(() => { + if (!pinned) return; + const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const node = popoverRef.current; + node?.querySelector("button")?.focus({ preventScroll: true }); + return () => { + const active = document.activeElement; + if (opener?.isConnected && (!active || active === document.body || node?.contains(active))) opener.focus({ preventScroll: true }); + }; + }, [pinned]); + useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index fdd4aff31..d6688fa41 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -277,29 +277,33 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal {importMode === "file" && ( - + fileInputRef.current?.click()} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} - className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors ${ + className={`w-full flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${ isDragging ? "border-primary bg-primary/5" : "border-border hover:border-primary/50 hover:bg-muted/50" }`} > - {t("select_file")} - {t("drop_file")} - {t("supported_formats")} + {t("select_file")} + {t("drop_file")} + {t("supported_formats")} + - + > )} {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. */} + { e.stopPropagation(); onSelectTask(task); }} + className="flex-1 min-w-0 text-start rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + > + {priorityIcon} - + - + {dueDateInfo && ( @@ -247,8 +251,8 @@ export function TaskListView({ {cal.name} )} - - + + ); })}
{t("select_file")}
{t("drop_file")}
{t("supported_formats")}