diff --git a/components/calendar/__tests__/event-card.test.tsx b/components/calendar/__tests__/event-card.test.tsx
index 11316f99e..71fd47d19 100644
--- a/components/calendar/__tests__/event-card.test.tsx
+++ b/components/calendar/__tests__/event-card.test.tsx
@@ -1,5 +1,5 @@
-import { render, screen, cleanup } from '@testing-library/react';
-import { describe, it, expect, afterEach, beforeEach } from 'vitest';
+import { render, screen, cleanup, fireEvent } from '@testing-library/react';
+import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
import { EventCard } from '../event-card';
import { useSettingsStore } from '@/stores/settings-store';
import type { Calendar, CalendarEvent, CalendarParticipant } from '@/lib/jmap/types';
@@ -139,3 +139,20 @@ describe('EventCard colour settings', () => {
expect(calendar.color).toBe('#22c55e');
});
});
+
+describe('EventCard from the keyboard (MA-05)', () => {
+ afterEach(cleanup);
+
+ it.each(['chip', 'span', 'block'] as const)('opens the event menu with Shift+F10 on the %s', (variant) => {
+ const onContextMenu = vi.fn();
+ const event = makeOccurrence('a', 'accepted');
+ render();
+ const card = screen.getByRole('button');
+ fireEvent.keyDown(card, { key: 'F10' });
+ expect(onContextMenu).not.toHaveBeenCalled();
+ fireEvent.keyDown(card, { key: 'F10', shiftKey: true });
+ expect(onContextMenu).toHaveBeenCalledOnce();
+ expect(onContextMenu.mock.calls[0][1]).toBe(event);
+ expect(onContextMenu.mock.calls[0][0].currentTarget).toBe(card);
+ });
+});
diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx
index 41c0fa219..3c4ee7357 100644
--- a/components/calendar/event-card.tsx
+++ b/components/calendar/event-card.tsx
@@ -191,6 +191,21 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
preventDefault: () => {}, stopPropagation: () => {},
} as unknown as React.MouseEvent, event);
}, !!onContextMenu);
+ // Shift+F10 opens the same menu from the keyboard (the menu key already
+ // fires contextmenu by itself). Prevented here, so a browser that also
+ // turns Shift+F10 into a contextmenu event doesn't open it twice.
+ const handleKeyDown = onContextMenu ? (e: React.KeyboardEvent) => {
+ if (!(e.shiftKey && e.key === "F10")) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const target = e.currentTarget;
+ const rect = target.getBoundingClientRect();
+ onContextMenu({
+ type: "contextmenu", clientX: rect.left, clientY: rect.bottom, detail: 0, currentTarget: target, target,
+ preventDefault: () => {}, stopPropagation: () => {},
+ } as unknown as React.MouseEvent, event);
+ } : undefined;
+
const touchProps = onContextMenu ? {
onTouchStart: (e: React.TouchEvent) => { pressedRef.current = e.currentTarget; longPress.onTouchStart(e); },
onTouchMove: longPress.onTouchMove,
@@ -205,6 +220,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
+ onKeyDown={handleKeyDown}
{...touchProps}
aria-label={ariaLabel}
tabIndex={tabbable ? undefined : -1}
@@ -236,6 +252,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
+ onKeyDown={handleKeyDown}
{...touchProps}
aria-label={ariaLabel}
tabIndex={tabbable ? undefined : -1}
@@ -270,6 +287,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
onMouseEnter={(e) => onMouseEnter?.(e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onMouseLeave?.()}
onContextMenu={handleContextMenu}
+ onKeyDown={handleKeyDown}
{...touchProps}
aria-label={ariaLabel}
tabIndex={tabbable ? undefined : -1}