diff --git a/components/calendar/__tests__/calendar-time-grid-keyboard.test.tsx b/components/calendar/__tests__/calendar-time-grid-keyboard.test.tsx new file mode 100644 index 000000000..5af65d961 --- /dev/null +++ b/components/calendar/__tests__/calendar-time-grid-keyboard.test.tsx @@ -0,0 +1,166 @@ +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { CalendarWeekView } from '../calendar-week-view'; +import { CalendarDayView } from '../calendar-day-view'; +import { useSettingsStore } from '@/stores/settings-store'; +import type { Calendar } from '@/lib/jmap/types'; + +const calendars = [{ id: 'cal-1', name: 'Work', color: '#123456' }] as unknown as Calendar[]; +const initialSettings = useSettingsStore.getState(); + +type WeekProps = React.ComponentProps; + +function renderWeek(overrides: Partial = {}) { + const props: WeekProps = { + focus: { date: new Date(2026, 8, 9), nonce: 0 }, + windowKey: 'week:2026-09-07', + // Monday 7 Sep to Sunday 13 Sep 2026 + rangeStart: new Date(2026, 8, 7), + rangeEnd: new Date(2026, 8, 13), + selectedDate: new Date(2026, 8, 9), + events: [], + calendars, + onSelectDate: vi.fn(), + onSelectEvent: vi.fn(), + onCreateAtTime: vi.fn(), + ...overrides, + }; + render(); + return props; +} + +function tabStops(): HTMLElement[] { + return screen.getAllByRole('gridcell').filter((cell) => cell.tabIndex === 0); +} + +function focusedSlot(): string | null { + return (document.activeElement as HTMLElement | null)?.getAttribute('data-slot') ?? null; +} + +describe('keyboard access to the time grid (CAL-13)', () => { + beforeEach(() => { + useSettingsStore.setState(initialSettings, true); + }); + + afterEach(() => { + cleanup(); + }); + + it('gives the week grid one tab stop on the selected day', () => { + renderWeek(); + const stops = tabStops(); + expect(stops).toHaveLength(1); + expect(stops[0].getAttribute('data-slot')).toMatch(/^2026-09-09T\d+$/); + // The slot is read out with its date and time (the test formatter + // prints the whole Date), not just "Wed 08:00" + expect(stops[0]).toHaveAccessibleName(/Sep 09 2026.*, \d\d:00$/); + // ...and one date in the header, not each loaded day's + const dates = screen.getAllByRole('columnheader').map((h) => h.querySelector('button')!); + expect(dates.filter((b) => b.tabIndex === 0)).toHaveLength(1); + }); + + it('moves by hour and day with the arrows and Home/End', () => { + renderWeek(); + const start = tabStops()[0]; + start.focus(); + fireEvent.keyDown(start, { key: 'Home' }); + expect(focusedSlot()).toBe('2026-09-09T8'); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }); + expect(focusedSlot()).toBe('2026-09-09T9'); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' }); + expect(focusedSlot()).toBe('2026-09-10T9'); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' }); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' }); + expect(focusedSlot()).toBe('2026-09-08T9'); + fireEvent.keyDown(document.activeElement!, { key: 'End' }); + expect(focusedSlot()).toBe('2026-09-08T19'); + // The edge of the visible hours stops the arrow + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }); + expect(focusedSlot()).toBe('2026-09-08T19'); + // The tab stop follows the focus + expect(tabStops().map((cell) => cell.getAttribute('data-slot'))).toEqual(['2026-09-08T19']); + }); + + it('keeps the arrows from paging the calendar', () => { + renderWeek(); + const start = tabStops()[0]; + start.focus(); + const event = new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true, cancelable: true }); + start.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('creates an event at the focused slot with Enter or n', () => { + const props = renderWeek(); + const start = tabStops()[0]; + start.focus(); + fireEvent.keyDown(start, { key: 'Home' }); + fireEvent.keyDown(document.activeElement!, { key: 'Enter' }); + expect(props.onCreateAtTime).toHaveBeenLastCalledWith(new Date(2026, 8, 9, 8, 0, 0, 0)); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }); + fireEvent.keyDown(document.activeElement!, { key: 'n', code: 'KeyN' }); + expect(props.onCreateAtTime).toHaveBeenLastCalledWith(new Date(2026, 8, 9, 9, 0, 0, 0)); + // Not with a modifier: Ctrl+N belongs to the browser + fireEvent.keyDown(document.activeElement!, { key: 'n', code: 'KeyN', ctrlKey: true }); + expect(props.onCreateAtTime).toHaveBeenCalledTimes(2); + }); + + it('leaves the slots out of the tab order on phones', () => { + renderWeek({ isMobile: true }); + expect(tabStops()).toHaveLength(0); + }); + + it('works the same in the day view', () => { + const onCreateAtTime = vi.fn(); + render( + , + ); + const stops = tabStops(); + expect(stops).toHaveLength(1); + expect(stops[0]).toHaveAccessibleName(/Sep 09 2026.*, \d\d:00$/); + stops[0].focus(); + fireEvent.keyDown(stops[0], { key: 'End' }); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' }); + expect(focusedSlot()).toBe('2026-09-10T19'); + fireEvent.keyDown(document.activeElement!, { key: 'Enter' }); + expect(onCreateAtTime).toHaveBeenCalledWith(new Date(2026, 8, 10, 19, 0, 0, 0)); + }); +}); + +describe('quick editor focus (CAL-13)', () => { + afterEach(() => { + cleanup(); + }); + + it('returns focus to the slot it was opened from', async () => { + const { QuickCreatePopover } = await import('../quick-create-popover'); + const slot = document.createElement('div'); + slot.tabIndex = -1; + document.body.appendChild(slot); + slot.focus(); + const { unmount } = render( + , + ); + (screen.getAllByRole('textbox')[0] as HTMLElement).focus(); + unmount(); + expect(document.activeElement).toBe(slot); + slot.remove(); + }); +}); diff --git a/components/calendar/calendar-day-view.tsx b/components/calendar/calendar-day-view.tsx index d4db6f55f..d927ee602 100644 --- a/components/calendar/calendar-day-view.tsx +++ b/components/calendar/calendar-day-view.tsx @@ -15,6 +15,7 @@ import { displayNow, isDisplayToday } from "@/lib/timezone"; import { groupTasksByDueDay } from "@/lib/calendar-tasks"; import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types"; import { useDisplayHours, type RevealTime } from "@/hooks/use-display-hours"; +import { useTimeGridFocus } from "@/hooks/use-time-grid-focus"; import { useTimeGridInteractions, type MoveEventHandler } from "@/hooks/use-time-grid-interactions"; import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window"; import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; @@ -297,6 +298,16 @@ export function CalendarDayView({ onMoveEvent, }); + const { slotProps } = useTimeGridFocus({ + rootRef, + headerRef, + gutterWidth, + days, + hours, + selectedDate, + onCreate: onCreateAtTime, + }); + const formatHour = (h: number): string => { if (timeFormat === "12h") { const d = new Date(2000, 0, 1, h); @@ -304,6 +315,8 @@ export function CalendarDayView({ } return format(new Date(2000, 0, 1, h), "HH:mm"); }; + // A slot is read out with its whole date: "Wednesday, October 28, 10:00". + const slotDateLabel = (day: Date) => intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" }); // Above every in-column overlay (events z-10, handles z-20, drag z-30) so // columns scrolled past the start do not show through the gutter. @@ -469,11 +482,12 @@ export function CalendarDayView({
handleSlotClick(day, h)} onDoubleClick={() => handleSlotDoubleClick(day, h)} onContextMenu={onContextMenuEmpty ? (e) => onContextMenuEmpty(e, day, h, false) : undefined} - className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors" + className="border-b border-border/50 hover:bg-muted/30 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary" style={{ height: hourHeight }} /> ))} diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx index ce8f4f230..506e394d4 100644 --- a/components/calendar/calendar-week-view.tsx +++ b/components/calendar/calendar-week-view.tsx @@ -18,6 +18,7 @@ import { displayNow, isDisplayToday } from "@/lib/timezone"; import type { CalendarEvent, Calendar, CalendarTask } from "@/lib/jmap/types"; import { useSettingsStore } from "@/stores/settings-store"; import { useDisplayHours, type RevealTime } from "@/hooks/use-display-hours"; +import { useTimeGridFocus } from "@/hooks/use-time-grid-focus"; import { useTimeGridInteractions, type MoveEventHandler } from "@/hooks/use-time-grid-interactions"; import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "@/hooks/use-scroll-window"; import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window"; @@ -393,6 +394,16 @@ export function CalendarWeekView({ onMoveEvent, }); + const { slotProps, tabStopDayKey } = useTimeGridFocus({ + rootRef, + headerRef, + gutterWidth, + days, + hours, + selectedDate, + onCreate: onCreateAtTime, + }); + const formatHour = (h: number): string => { if (timeFormat === "12h") { const d = new Date(2000, 0, 1, h); @@ -400,6 +411,8 @@ export function CalendarWeekView({ } return format(new Date(2000, 0, 1, h), "HH:mm"); }; + // A slot is read out with its whole date: "Wednesday, October 28, 10:00". + const slotDateLabel = (day: Date) => intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" }); // Above every in-column overlay (events z-10, handles z-20, drag z-30) so // columns scrolled past the start do not show through the gutter. @@ -451,6 +464,8 @@ export function CalendarWeekView({