From 6eaaca5cfa020cb21e318dff066753a4dce4d982 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:50:23 +0100 Subject: [PATCH] fix: move the focus into an event's card once it shows, so Enter on an event lands inside it --- .../event-detail-popover-focus.test.tsx | 70 +++++++++++++++++++ components/calendar/event-detail-popover.tsx | 25 ++++++- 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 components/calendar/__tests__/event-detail-popover-focus.test.tsx diff --git a/components/calendar/__tests__/event-detail-popover-focus.test.tsx b/components/calendar/__tests__/event-detail-popover-focus.test.tsx new file mode 100644 index 000000000..787cc35c8 --- /dev/null +++ b/components/calendar/__tests__/event-detail-popover-focus.test.tsx @@ -0,0 +1,70 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { EventDetailPopover } from '../event-detail-popover'; +import type { Calendar, CalendarEvent } from '@/lib/jmap/types'; + +// CAL-13: Enter on an event opened the card but the focus stayed on the +// event: the card focused its first button while it was still hidden, +// before it had been placed. + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => 'en', + useFormatter: () => ({ + dateTime: (d: Date | string) => String(d), + relativeTime: (d: Date | string) => String(d), + number: (n: number) => String(n), + }), +})); + +const calendar = { + id: 'cal-1', + name: 'Work', + myRights: { mayWriteAll: true, mayWriteOwn: true }, +} as unknown as Calendar; + +const event = { + id: 'evt-1', + calendarIds: { 'cal-1': true }, + title: 'Review', + start: '2026-11-20T10:00:00', + duration: 'PT1H', + timeZone: 'Europe/Berlin', + showWithoutTime: false, + participants: null, + alerts: null, +} as unknown as CalendarEvent; + +describe('EventDetailPopover focus', () => { + it('takes the focus once it is visible, and gives it back to the event on close', () => { + const opener = document.createElement('button'); + document.body.appendChild(opener); + opener.focus(); + const visibilityAtFocus: string[] = []; + const onFocusIn = (e: FocusEvent) => { + const card = (e.target as HTMLElement).closest('[role="dialog"]'); + if (card) visibilityAtFocus.push(card.style.visibility); + }; + document.addEventListener('focusin', onFocusIn); + const { unmount } = render( + , + ); + const card = screen.getByRole('dialog', { name: 'Review' }); + expect(card.contains(document.activeElement)).toBe(true); + expect(visibilityAtFocus).toEqual(['visible']); + unmount(); + document.removeEventListener('focusin', onFocusIn); + expect(document.activeElement).toBe(opener); + opener.remove(); + }); +}); diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index b99da1688..63399b897 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -253,17 +253,40 @@ export function EventDetailPopover({ return () => observer.disconnect(); }, [anchorRect, isMobile]); + // A card opened by click or Enter takes the focus, and gives it back to + // the event when it closes. useEffect(() => { if (!pinned) return; const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null; const node = popoverRef.current; - node?.querySelector("button")?.focus({ preventScroll: true }); return () => { const active = document.activeElement; if (opener?.isConnected && (!active || active === document.body || node?.contains(active))) opener.focus({ preventScroll: true }); }; }, [pinned]); + // The focus goes in once the card is placed: before, it is hidden and + // can't take the focus, which stayed on the event. A card that just + // turned visible can still count as hidden for a frame, so it tries again + // on the next ones, unless the focus moved somewhere else meanwhile. + const placed = position !== null; + useEffect(() => { + const node = popoverRef.current; + if (!pinned || !placed || !node) return; + const from = document.activeElement; + let frame = 0; + let tries = 0; + const attempt = () => { + const active = document.activeElement; + if (node.contains(active) || active !== from) return; + const first = node.querySelector("button"); + first?.focus({ preventScroll: true }); + if (first && document.activeElement !== first && ++tries < 30) frame = requestAnimationFrame(attempt); + }; + attempt(); + return () => cancelAnimationFrame(frame); + }, [pinned, placed]); + useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose();