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

This commit is contained in:
Linus Rath
2026-10-28 04:12:44 +01:00
parent d1bb44fe2e
commit b925b16737
7 changed files with 121 additions and 14 deletions
@@ -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();
});
});
@@ -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(<ICalImportModal calendars={calendars} client={client} onClose={vi.fn()} />);
expect(screen.getByRole('button', { name: /select_file/ })).toBeInTheDocument();
});
});
+1
View File
@@ -2238,6 +2238,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
isSubscriptionCalendar={isSubscriptionCalendar}
timeFormat={timeFormat}
isMobile={isMobile}
pinned={detailPinned}
/>
)}
+35 -1
View File
@@ -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<HTMLButtonElement>, 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<HTMLButtonElement>(`[data-day-button="${dayKey(next)}"]`);
if (!target) return;
onSelectDate(next);
target.focus();
}, [onSelectDate]);
const [dropDayKey, setDropDayKey] = useState<string | null>(null);
const handleCellDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
@@ -402,7 +434,9 @@ export function CalendarMonthView({
whole cell already does. */}
<button
type="button"
tabIndex={isMobile ? -1 : undefined}
data-day-button={key}
tabIndex={!isMobile && key === rovingKey ? 0 : -1}
onKeyDown={(e) => handleDayKeyDown(e, day, dayIndex)}
onClick={(e) => {
if (isMobile || !onOpenDay) return;
e.stopPropagation();
@@ -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<HTMLElement>("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();
+10 -6
View File
@@ -277,29 +277,33 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
</div>
{importMode === "file" && (
<div
<>
<button
type="button"
onClick={() => 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"
}`}
>
<Upload className="w-8 h-8 text-muted-foreground mb-3" />
<p className="text-sm font-medium">{t("select_file")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("drop_file")}</p>
<p className="text-xs text-muted-foreground mt-2">{t("supported_formats")}</p>
<span className="block text-sm font-medium">{t("select_file")}</span>
<span className="block text-xs text-muted-foreground mt-1">{t("drop_file")}</span>
<span className="block text-xs text-muted-foreground mt-2">{t("supported_formats")}</span>
</button>
<input
ref={fileInputRef}
type="file"
accept=".ics,.ical"
onChange={handleFileChange}
className="hidden"
tabIndex={-1}
/>
</div>
</>
)}
{importMode === "url" && (
+11 -7
View File
@@ -222,9 +222,13 @@ export function TaskListView({
{isCompleted && <Check className="h-3 w-3" />}
</button>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
{/* Content: a button, so Tab reaches the task and Enter opens it. */}
<button
type="button"
onClick={(e) => { 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"
>
<span className="flex items-center gap-1.5">
<span className={cn(
"text-sm font-medium truncate",
isCompleted && "line-through text-muted-foreground"
@@ -232,9 +236,9 @@ export function TaskListView({
{task.title || t("tasks.no_title")}
</span>
{priorityIcon}
</div>
</span>
<div className="flex items-center gap-2 mt-0.5">
<span className="flex items-center gap-2 mt-0.5">
{dueDateInfo && (
<span className={cn("text-xs flex items-center gap-1", dueDateInfo.className)}>
<CalendarDays className="h-3 w-3" />
@@ -247,8 +251,8 @@ export function TaskListView({
{cal.name}
</span>
)}
</div>
</div>
</span>
</button>
</div>
);
})}