mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
fix: keep calendar shortcuts off Ctrl, Cmd and Alt combinations and working under the hover preview
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user