mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: make the calendar usable from the keyboard: one tab stop and arrow keys in the month grid, focus into the event card and back, reachable tasks and import drop zone
This commit is contained in:
@@ -494,3 +494,43 @@ describe('CalendarApp event menu', () => {
|
||||
expect(await screen.findByPlaceholderText('form.title')).toHaveValue('Standup');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CalendarApp from the keyboard', () => {
|
||||
it('gives the month grid one tab stop and moves between days with the arrows', () => {
|
||||
setUp([], 'month');
|
||||
const { container } = renderApp();
|
||||
const tabbable = container.querySelectorAll('[data-day-button][tabindex="0"]');
|
||||
expect(tabbable).toHaveLength(1);
|
||||
const today = tabbable[0] as HTMLButtonElement;
|
||||
today.focus();
|
||||
fireEvent.keyDown(today, { key: 'ArrowRight' });
|
||||
expect(useCalendarStore.getState().selectedDate).toEqual(new Date(2026, 9, 13));
|
||||
expect(document.activeElement).toBe(container.querySelector('[data-day-button="2026-10-13"]'));
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' });
|
||||
expect(useCalendarStore.getState().selectedDate).toEqual(new Date(2026, 9, 20));
|
||||
// The arrows moved the selection, not the calendar a month on.
|
||||
expect(useCalendarStore.getState().viewMode).toBe('month');
|
||||
});
|
||||
|
||||
it('moves the focus into an opened event card and back to the event on Escape', async () => {
|
||||
renderApp();
|
||||
const card = screen.getAllByRole('button', { name: /Standup/ })[0];
|
||||
card.focus();
|
||||
fireEvent.click(card, { detail: 0 });
|
||||
const popover = await screen.findByRole('dialog', { name: 'Standup' });
|
||||
await waitFor(() => expect(popover.contains(document.activeElement)).toBe(true));
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Standup' })).toBeNull());
|
||||
expect(document.activeElement).toBe(card);
|
||||
});
|
||||
|
||||
it('reaches a task with Tab and opens it', async () => {
|
||||
setUp([], 'tasks');
|
||||
const task = { id: 't1', '@type': 'Task', title: 'Buy milk', calendarIds: { cal: true }, progress: 'needs-action', priority: 0, due: null, showWithoutTime: true } as never;
|
||||
useTaskStore.setState({ tasks: [task] } as never);
|
||||
client.getCalendarTasks.mockResolvedValue([task]);
|
||||
renderApp();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Buy milk/ }));
|
||||
expect(screen.getByRole('heading', { name: 'tasks.edit' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,3 +55,10 @@ describe('calendar dialogs', () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('import dialog from the keyboard', () => {
|
||||
it('lets the keyboard reach the file drop zone', () => {
|
||||
render(<ICalImportModal calendars={calendars} client={client} onClose={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: /select_file/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2238,6 +2238,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
isSubscriptionCalendar={isSubscriptionCalendar}
|
||||
timeFormat={timeFormat}
|
||||
isMobile={isMobile}
|
||||
pinned={detailPinned}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -274,6 +274,38 @@ export function CalendarMonthView({
|
||||
anchorSelector: "[data-week]",
|
||||
});
|
||||
|
||||
// One tab stop for the whole grid (the selected day's date), arrows move
|
||||
// from day to day and week to week, Enter opens the day (R8): Tab used to
|
||||
// walk every date of the loaded months.
|
||||
const selectedKey = dayKey(selectedDate);
|
||||
const rovingKey = useMemo(() => {
|
||||
const keys = new Set(weeks.flat().map(dayKey));
|
||||
if (keys.has(selectedKey)) return selectedKey;
|
||||
const focusKey = dayKey(focus.date);
|
||||
return keys.has(focusKey) ? focusKey : (weeks[0]?.[0] ? dayKey(weeks[0][0]) : selectedKey);
|
||||
}, [weeks, selectedKey, focus.date]);
|
||||
const handleDayKeyDown = useCallback((e: React.KeyboardEvent<HTMLButtonElement>, day: Date, dayIndex: number) => {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
|
||||
const rtl = getComputedStyle(e.currentTarget).direction === "rtl";
|
||||
let delta: number;
|
||||
switch (e.key) {
|
||||
case "ArrowLeft": delta = rtl ? 1 : -1; break;
|
||||
case "ArrowRight": delta = rtl ? -1 : 1; break;
|
||||
case "ArrowUp": delta = -7; break;
|
||||
case "ArrowDown": delta = 7; break;
|
||||
case "Home": delta = -dayIndex; break;
|
||||
case "End": delta = 6 - dayIndex; break;
|
||||
default: return;
|
||||
}
|
||||
// The grid's arrows, not the calendar's previous/next month.
|
||||
e.preventDefault();
|
||||
const next = addDays(day, delta);
|
||||
const target = scrollRef.current?.querySelector<HTMLButtonElement>(`[data-day-button="${dayKey(next)}"]`);
|
||||
if (!target) return;
|
||||
onSelectDate(next);
|
||||
target.focus();
|
||||
}, [onSelectDate]);
|
||||
|
||||
const [dropDayKey, setDropDayKey] = useState<string | null>(null);
|
||||
|
||||
const handleCellDragOver = useCallback((e: DragEvent<HTMLDivElement>, dayKey: string) => {
|
||||
@@ -402,7 +434,9 @@ export function CalendarMonthView({
|
||||
whole cell already does. */}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={isMobile ? -1 : undefined}
|
||||
data-day-button={key}
|
||||
tabIndex={!isMobile && key === rovingKey ? 0 : -1}
|
||||
onKeyDown={(e) => handleDayKeyDown(e, day, dayIndex)}
|
||||
onClick={(e) => {
|
||||
if (isMobile || !onOpenDay) return;
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -47,6 +47,11 @@ interface EventDetailPopoverProps {
|
||||
isSubscriptionCalendar?: (calendarId: string) => boolean;
|
||||
timeFormat?: "12h" | "24h";
|
||||
isMobile?: boolean;
|
||||
/**
|
||||
* Opened by a click or Enter rather than shown on hover: it takes the
|
||||
* focus, and gives it back to the event when it closes.
|
||||
*/
|
||||
pinned?: boolean;
|
||||
}
|
||||
|
||||
const POPOVER_WIDTH = 360;
|
||||
@@ -125,6 +130,7 @@ export function EventDetailPopover({
|
||||
isSubscriptionCalendar,
|
||||
timeFormat = "24h",
|
||||
isMobile,
|
||||
pinned = false,
|
||||
}: EventDetailPopoverProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const locale = useLocale();
|
||||
@@ -244,6 +250,17 @@ export function EventDetailPopover({
|
||||
return () => observer.disconnect();
|
||||
}, [anchorRect, isMobile]);
|
||||
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
|
||||
@@ -277,29 +277,33 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
|
||||
</div>
|
||||
|
||||
{importMode === "file" && (
|
||||
<div
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors ${
|
||||
className={`w-full flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
||||
isDragging
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Upload className="w-8 h-8 text-muted-foreground mb-3" />
|
||||
<p className="text-sm font-medium">{t("select_file")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">{t("drop_file")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">{t("supported_formats")}</p>
|
||||
<span className="block text-sm font-medium">{t("select_file")}</span>
|
||||
<span className="block text-xs text-muted-foreground mt-1">{t("drop_file")}</span>
|
||||
<span className="block text-xs text-muted-foreground mt-2">{t("supported_formats")}</span>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".ics,.ical"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{importMode === "url" && (
|
||||
|
||||
@@ -222,9 +222,13 @@ export function TaskListView({
|
||||
{isCompleted && <Check className="h-3 w-3" />}
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Content: a button, so Tab reaches the task and Enter opens it. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onSelectTask(task); }}
|
||||
className="flex-1 min-w-0 text-start rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={cn(
|
||||
"text-sm font-medium truncate",
|
||||
isCompleted && "line-through text-muted-foreground"
|
||||
@@ -232,9 +236,9 @@ export function TaskListView({
|
||||
{task.title || t("tasks.no_title")}
|
||||
</span>
|
||||
{priorityIcon}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="flex items-center gap-2 mt-0.5">
|
||||
{dueDateInfo && (
|
||||
<span className={cn("text-xs flex items-center gap-1", dueDateInfo.className)}>
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
@@ -247,8 +251,8 @@ export function TaskListView({
|
||||
{cal.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user