diff --git a/components/calendar/__tests__/calendar-app.test.tsx b/components/calendar/__tests__/calendar-app.test.tsx new file mode 100644 index 000000000..e75c2cab4 --- /dev/null +++ b/components/calendar/__tests__/calendar-app.test.tsx @@ -0,0 +1,124 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useToastStore } from '@/stores/toast-store'; + +// The whole calendar page, with the shell around it stubbed out and the +// stores holding a fixed set of calendars and events. + +const media = vi.hoisted(() => ({ mobile: false, desktop: true })); +vi.mock('@/hooks/use-media-query', () => ({ + useIsMobile: () => media.mobile, + useIsDesktop: () => media.desktop, + useIsTablet: () => !media.mobile && !media.desktop, + useMediaQuery: () => false, +})); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null })); +vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null })); +vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null })); +vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null })); +vi.mock('@/hooks/use-refresh-gesture', () => ({ useRefreshGesture: () => ({ indicator: null }) })); +vi.mock('@/hooks/use-calendar-push-refresh', () => ({ useCalendarPushRefresh: () => {} })); +vi.mock('@/hooks/use-account-principal-emails', () => ({ useAccountPrincipalEmails: () => [] })); +vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null })); + +import { CalendarApp } from '../calendar-app'; + +// The first render loads the whole page and is slow under load. +vi.setConfig({ testTimeout: 30000 }); + +const RIGHTS = { + mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, + mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true, +}; + +const calendars = [{ + id: 'cal', name: 'Personal', color: '#123456', isDefault: true, sortOrder: 0, description: null, + isSubscribed: true, isVisible: true, includeInAvailability: 'all', defaultAlertsWithTime: null, + defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS, +}] as unknown as Calendar[]; + +function makeEvent(overrides: Partial = {}): CalendarEvent { + return { + id: 'e1', uid: 'u1', '@type': 'Event', title: 'Standup', description: '', start: '2026-10-12T10:00:00', + duration: 'PT1H', timeZone: null, showWithoutTime: false, calendarIds: { cal: true }, + participants: null, alerts: null, recurrenceRules: null, isOrigin: true, status: 'confirmed', + ...overrides, + } as unknown as CalendarEvent; +} + +const client = { + getAccountId: () => 'acc', + getMaxParticipantsPerEvent: () => null, + supportsCalendars: () => true, + queryCalendarEvents: vi.fn().mockResolvedValue([]), + getCalendarEvent: vi.fn().mockResolvedValue(null), + getCalendarTasks: vi.fn().mockResolvedValue([]), +}; + +const store = { + fetchCalendars: vi.fn(), + fetchEvents: vi.fn(), + refreshAllSubscriptions: vi.fn(), + createEvent: vi.fn(), + updateEvent: vi.fn(), + deleteEvent: vi.fn(), + rsvpEvent: vi.fn(), +}; + +function setUp(events: CalendarEvent[] = [makeEvent()], viewMode: 'month' | 'week' | 'day' | 'agenda' | 'tasks' = 'week') { + for (const fn of Object.values(store)) fn.mockReset(); + store.createEvent.mockImplementation(async (_c: unknown, data: Partial) => ({ ...makeEvent(), ...data, id: 'new' })); + store.updateEvent.mockResolvedValue(true); + store.deleteEvent.mockResolvedValue(true); + useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' }); + useCalendarStore.setState({ + calendars, events, supportsCalendar: true, selectedCalendarIds: ['cal'], viewMode, + selectedDate: new Date(2026, 9, 12), isLoading: false, isLoadingEvents: false, error: null, + ...store, + } as never); + useSettingsStore.setState({ calendarHoverPreview: 'instant', enableCalendarTasks: true, proInterface: false } as never); + useToastStore.setState({ toasts: [] } as never); +} + +function renderApp() { + return render(); +} + +beforeEach(() => { + media.mobile = false; + media.desktop = true; + setUp(); +}); + +describe('CalendarApp keyboard shortcuts', () => { + it('leaves Ctrl, Cmd and Alt combinations to the browser', () => { + renderApp(); + fireEvent.keyDown(document.body, { key: 'a', code: 'KeyA', ctrlKey: true }); + fireEvent.keyDown(document.body, { key: 'd', code: 'KeyD', metaKey: true }); + fireEvent.keyDown(document.body, { key: 'ArrowLeft', code: 'ArrowLeft', altKey: true }); + expect(useCalendarStore.getState().viewMode).toBe('week'); + expect(useCalendarStore.getState().selectedDate).toEqual(new Date(2026, 9, 12)); + }); + + it('switches views by the physical key on any layout', () => { + renderApp(); + // A Cyrillic layout types "ф" on the A key. + fireEvent.keyDown(document.body, { key: 'ф', code: 'KeyA' }); + expect(useCalendarStore.getState().viewMode).toBe('agenda'); + }); + + it('keeps working while the hover preview of an event shows', async () => { + renderApp(); + const card = screen.getAllByRole('button', { name: /Standup/ })[0]; + fireEvent.mouseEnter(card); + await screen.findByRole('dialog', { name: 'Standup' }); + fireEvent.keyDown(document.body, { key: 'm', code: 'KeyM' }); + expect(useCalendarStore.getState().viewMode).toBe('month'); + }); +}); + diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index d91420215..476fa6732 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -96,6 +96,9 @@ import { MvTopBar } from "@/components/layout/mv-top-bar"; import { useMountainViewShell } from "@/hooks/use-mountain-view-shell"; import { calendarHooks } from "@/lib/plugin-hooks"; import { eventToView } from "@/lib/plugin-projection"; +import { calendarShortcutAction } from "@/lib/calendar-shortcuts"; +import { isEditableEventTarget } from "@/lib/keyboard"; +import { isContextMenuOpen } from "@/components/ui/context-menu"; type PendingScopeAction = | { type: "edit"; event: CalendarEvent; updates: Partial; editor?: EventEditorState; sendScheduling?: boolean } @@ -1327,26 +1330,31 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = useEffect(() => { const handleKey = (e: KeyboardEvent) => { - const target = e.target as HTMLElement; - if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT") return; - if (target.getAttribute("contenteditable") === "true") return; - if (showEventModal || detailEvent) return; + if (e.defaultPrevented) return; + if (isEditableEventTarget(e)) return; + // An open menu or dialog owns the keyboard; a hover preview doesn't, + // only a card the user opened. + if (isContextMenuOpen()) return; + if (document.querySelector('[aria-modal="true"]')) return; + if (showEventModal || showTaskModal || detailPinned) return; - switch (e.key) { - case "ArrowLeft": e.preventDefault(); navigatePrev(); break; - case "ArrowRight": e.preventDefault(); navigateNext(); break; - case "t": goToToday(); break; - case "m": setViewMode("month"); break; - case "w": setViewMode("week"); break; - case "d": setViewMode("day"); break; - case "a": setViewMode("agenda"); break; - case "k": if (enableCalendarTasks) setViewMode("tasks"); break; - case "n": openCreateModal(); break; + const action = calendarShortcutAction(e); + if (!action) return; + switch (action) { + case "prev": e.preventDefault(); navigatePrev(); break; + case "next": e.preventDefault(); navigateNext(); break; + case "today": goToToday(); break; + case "month": setViewMode("month"); break; + case "week": setViewMode("week"); break; + case "day": setViewMode("day"); break; + case "agenda": setViewMode("agenda"); break; + case "tasks": if (enableCalendarTasks) setViewMode("tasks"); break; + case "new": openCreateModal(); break; } }; window.addEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey); - }, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, detailEvent, enableCalendarTasks]); + }, [navigatePrev, navigateNext, goToToday, setViewMode, openCreateModal, showEventModal, showTaskModal, detailPinned, enableCalendarTasks]); const birthdayEvents = useMemo(() => { if (!showBirthdayCalendar || !dateRange) return []; diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 4ab0eba77..8c3b44e8f 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -27,6 +27,7 @@ import { useContactNameResolver } from "@/hooks/use-contact-name-resolver"; import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { eventToView } from "@/lib/plugin-projection"; +import { isEditableEventTarget } from "@/lib/keyboard"; interface EventDetailPopoverProps { event: CalendarEvent; @@ -247,11 +248,9 @@ export function EventDetailPopover({ useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); - const target = e.target as HTMLElement; - const tag = target?.tagName?.toLowerCase(); - if (tag === "input" || tag === "textarea" || tag === "select") return; - if (target?.getAttribute("contenteditable") === "true") return; - if (e.key === "e" && !noteExpanded && canEditBody) { + if (isEditableEventTarget(e)) return; + if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return; + if (e.code === "KeyE" && !noteExpanded && canEditBody) { e.preventDefault(); onEdit(); } diff --git a/lib/calendar-shortcuts.ts b/lib/calendar-shortcuts.ts new file mode 100644 index 000000000..01c0de843 --- /dev/null +++ b/lib/calendar-shortcuts.ts @@ -0,0 +1,39 @@ +// The calendar's single-key shortcuts. A key held with Ctrl, ⌘, Alt or +// Shift is left to the browser (Ctrl+A selects, Alt+← goes back), and +// letters come from the physical key, so they work on any layout (R8). + +export type CalendarShortcutAction = + | "prev" + | "next" + | "today" + | "month" + | "week" + | "day" + | "agenda" + | "tasks" + | "new"; + +const LETTERS: Record = { + t: "today", + m: "month", + w: "week", + d: "day", + a: "agenda", + k: "tasks", + n: "new", + c: "new", +}; + +/** The action a key press asks for, or null when it isn't a calendar shortcut. */ +export function calendarShortcutAction( + event: Pick, +): CalendarShortcutAction | null { + if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null; + if (event.key === "ArrowLeft") return "prev"; + if (event.key === "ArrowRight") return "next"; + const code = event.code; + const letter = code && code.length === 4 && code.startsWith("Key") + ? code.charAt(3).toLowerCase() + : event.key.length === 1 ? event.key.toLowerCase() : ""; + return LETTERS[letter] ?? null; +}