fix: move the focus into an event's card once it shows, so Enter on an event lands inside it

This commit is contained in:
Linus Rath
2026-10-28 09:50:23 +01:00
parent 3a888e11cc
commit 6eaaca5cfa
2 changed files with 94 additions and 1 deletions
@@ -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<HTMLElement>('[role="dialog"]');
if (card) visibilityAtFocus.push(card.style.visibility);
};
document.addEventListener('focusin', onFocusIn);
const { unmount } = render(
<EventDetailPopover
event={event}
calendar={calendar}
anchorRect={new DOMRect(100, 100, 50, 20)}
pinned
onEdit={vi.fn()}
onDelete={vi.fn()}
onDuplicate={vi.fn()}
onClose={vi.fn()}
onSaveNote={vi.fn()}
/>,
);
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();
});
});
+24 -1
View File
@@ -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<HTMLElement>("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<HTMLElement>("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();