fix: keep calendar shortcuts off Ctrl, Cmd and Alt combinations and working under the hover preview

This commit is contained in:
Linus Rath
2026-10-28 02:40:48 +01:00
parent 5a898dcfd7
commit bfe84ae8f3
4 changed files with 190 additions and 20 deletions
@@ -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> = {}): 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<CalendarEvent>) => ({ ...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(<CalendarApp />);
}
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');
});
});
+23 -15
View File
@@ -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<CalendarEvent>; 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 [];
+4 -5
View File
@@ -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();
}
+39
View File
@@ -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<string, CalendarShortcutAction> = {
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<KeyboardEvent, "key" | "code" | "ctrlKey" | "metaKey" | "altKey" | "shiftKey">,
): 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;
}