From d1bb44fe2e43ee590dd43bdd81c2da2eb6995b5d Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:06:46 +0100
Subject: [PATCH] fix: open the event editor in Title and give the import,
subscription, new-calendar and webcal dialogs the standard dialog behaviour
---
.../__tests__/calendar-dialogs.test.tsx | 57 +++++
components/calendar/calendar-app.tsx | 28 +--
components/calendar/create-calendar-modal.tsx | 141 ++++-------
components/calendar/event-modal.tsx | 53 +++--
components/calendar/ical-import-modal.tsx | 121 ++++------
.../calendar/ical-subscription-modal.tsx | 220 ++++++++----------
6 files changed, 278 insertions(+), 342 deletions(-)
create mode 100644 components/calendar/__tests__/calendar-dialogs.test.tsx
diff --git a/components/calendar/__tests__/calendar-dialogs.test.tsx b/components/calendar/__tests__/calendar-dialogs.test.tsx
new file mode 100644
index 000000000..9ec311ce1
--- /dev/null
+++ b/components/calendar/__tests__/calendar-dialogs.test.tsx
@@ -0,0 +1,57 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import type { Calendar } from '@/lib/jmap/types';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+
+// The event editor opened with the focus on its close button, so typing
+// went nowhere; the import, subscription and new-calendar dialogs were
+// hand-rolled: Escape only worked from some places and no focus handling.
+
+vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
+
+import { EventModal } from '../event-modal';
+import { ICalImportModal } from '../ical-import-modal';
+import { ICalSubscriptionModal } from '../ical-subscription-modal';
+import { CreateCalendarModal } from '../create-calendar-modal';
+
+const calendars = [{
+ id: 'cal', name: 'Personal', color: '#123456', isDefault: true,
+ myRights: { mayReadItems: true, mayWriteAll: true, mayWriteOwn: true, mayDelete: true },
+}] as unknown as Calendar[];
+const client = { getAccountId: () => 'acct' } as unknown as IJMAPClient;
+
+describe('calendar dialogs', () => {
+ it('opens a new event with the cursor in Title', () => {
+ render();
+ expect(document.activeElement).toBe(screen.getByPlaceholderText('form.title'));
+ });
+
+ it('closes the import dialog with Escape, wherever the focus is', () => {
+ const onClose = vi.fn();
+ render();
+ const dialog = screen.getByRole('dialog', { name: 'title' });
+ expect(dialog).toHaveAttribute('aria-modal', 'true');
+ fireEvent.keyDown(document.body, { key: 'Escape' });
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('opens the subscription dialog in the address field, with a named close button', () => {
+ const onClose = vi.fn();
+ render();
+ expect(document.activeElement).toBe(screen.getByLabelText('url_label'));
+ fireEvent.click(screen.getByRole('button', { name: 'close' }));
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('opens the new-calendar dialog in the name field and keeps typed input on an outside click', () => {
+ const onClose = vi.fn();
+ render();
+ const name = screen.getByLabelText('name');
+ expect(document.activeElement).toBe(name);
+ fireEvent.change(name, { target: { value: 'Gym' } });
+ const backdrop = screen.getByRole('dialog').parentElement!;
+ fireEvent.mouseDown(backdrop);
+ fireEvent.click(backdrop);
+ expect(onClose).not.toHaveBeenCalled();
+ });
+});
diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx
index 245cf2403..053508b2b 100644
--- a/components/calendar/calendar-app.tsx
+++ b/components/calendar/calendar-app.tsx
@@ -69,6 +69,7 @@ import { cn, generateUUID } from "@/lib/utils";
import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRights, CalendarTask } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
+import { Dialog } from "@/components/ui/dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { SchedulingDeniedError } from "@/lib/jmap/scheduling-error";
import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal";
@@ -1686,19 +1687,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
) : null;
const renderWebcalActionChoice = () => showWebcalActionChoice && pendingSubscription ? (
-
-
-
-
-
{tWebcalAction("title")}
-
{tWebcalAction("description", { name: pendingSubscription.name })}
-
-
+
-
+
) : null;
if (!isAuthenticated) return null;
diff --git a/components/calendar/create-calendar-modal.tsx b/components/calendar/create-calendar-modal.tsx
index d18a190f6..d9a4de6e6 100644
--- a/components/calendar/create-calendar-modal.tsx
+++ b/components/calendar/create-calendar-modal.tsx
@@ -1,9 +1,10 @@
"use client";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useId, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
-import { X, Loader2, Calendar as CalendarIcon } from "@/components/icons";
+import { Dialog } from "@/components/ui/dialog";
+import { Spinner } from "@/components/ui/spinner";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useCalendarStore } from "@/stores/calendar-store";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -24,7 +25,9 @@ export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProp
const [color, setColor] = useState("#3b82f6");
const [kind, setKind] = useState
("events");
const [isSubmitting, setIsSubmitting] = useState(false);
- const modalRef = useRef(null);
+ const nameRef = useRef(null);
+ const nameId = useId();
+ const colorId = useId();
const isValid = name.trim().length > 0;
@@ -47,109 +50,63 @@ export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProp
}
}, [name, color, kind, client, createCalendar, onClose, t]);
- useEffect(() => {
- const handleKey = (e: KeyboardEvent) => {
- if (e.key === "Escape" && !isSubmitting) onClose();
- };
- window.addEventListener("keydown", handleKey);
- return () => window.removeEventListener("keydown", handleKey);
- }, [onClose, isSubmitting]);
-
- useEffect(() => {
- const modal = modalRef.current;
- if (!modal) return;
- const focusableEls = modal.querySelectorAll(
- 'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
- );
- const firstEl = focusableEls[0];
- const lastEl = focusableEls[focusableEls.length - 1];
-
- const handler = (e: KeyboardEvent) => {
- if (e.key !== "Tab") return;
- if (e.shiftKey && document.activeElement === firstEl) {
- e.preventDefault();
- lastEl?.focus();
- } else if (!e.shiftKey && document.activeElement === lastEl) {
- e.preventDefault();
- firstEl?.focus();
- }
- };
- modal.addEventListener("keydown", handler);
- firstEl?.focus();
- return () => modal.removeEventListener("keydown", handler);
- }, []);
+ // Nothing closes it while the calendar is being created.
+ const close = useCallback(() => {
+ if (!isSubmitting) onClose();
+ }, [isSubmitting, onClose]);
return (
-
-
!isSubmitting && onClose()}
- aria-hidden="true"
- />
-
-
-
-
-
{t("add_calendar")}
-
-
-
-
-
-
-
- setName(e.target.value)}
- placeholder={t("name_placeholder")}
- className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
- disabled={isSubmitting}
- onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
- />
-
-
-
-
-
-
-
-
-
-
-
-
+
);
}
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx
index d6d1e4751..dd264c6f1 100644
--- a/components/calendar/event-modal.tsx
+++ b/components/calendar/event-modal.tsx
@@ -46,6 +46,7 @@ import type { CalendarEventView, ConflictWarning } from "@/lib/plugin-types";
import { eventToView } from "@/lib/plugin-projection";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
+import { useFocusTrap } from "@/hooks/use-focus-trap";
export interface PendingEventPreview {
start: Date;
@@ -910,7 +911,29 @@ export function EventModal({
onDuplicate(buildDuplicateEventData(event));
}, [event, onDuplicate]);
- const modalRef = useRef
(null);
+ const titleRef = useRef(null);
+ const primaryRef = useRef(null);
+ const initialFocusTarget = useCallback(
+ (): HTMLElement | null => (mode === "edit" ? titleRef.current : primaryRef.current),
+ [mode],
+ );
+ // Full screen on the phone: a modal that holds the focus.
+ const modalRef = useFocusTrap({ isActive: !!isMobile, initialFocus: initialFocusTarget });
+
+ // Beside the grid on wide screens: the focus moves in on open and with
+ // each switch between summary and form, and goes back on close.
+ useEffect(() => {
+ if (isMobile) return;
+ initialFocusTarget()?.focus();
+ }, [isMobile, initialFocusTarget]);
+ useEffect(() => {
+ if (isMobile) return;
+ const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ return () => {
+ const active = document.activeElement;
+ if (opener?.isConnected && (!active || active === document.body)) opener.focus();
+ };
+ }, [isMobile]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
@@ -931,30 +954,6 @@ export function EventModal({
return () => window.removeEventListener("keydown", handleKey);
}, [requestClose, backToView, handleSave, canEditBody, mode, isEdit]);
- useEffect(() => {
- const modal = modalRef.current;
- if (!modal) return;
- const focusableEls = modal.querySelectorAll(
- 'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
- );
- const firstEl = focusableEls[0];
- const lastEl = focusableEls[focusableEls.length - 1];
-
- const handler = (e: KeyboardEvent) => {
- if (e.key !== "Tab") return;
- if (e.shiftKey && document.activeElement === firstEl) {
- e.preventDefault();
- lastEl?.focus();
- } else if (!e.shiftKey && document.activeElement === lastEl) {
- e.preventDefault();
- firstEl?.focus();
- }
- };
- modal.addEventListener("keydown", handler);
- firstEl?.focus();
- return () => modal.removeEventListener("keydown", handler);
- }, []);
-
const hasParticipants = attendees.length > 0 || (event?.participants && Object.keys(event.participants).length > 0);
if (rsvpMode && event) {
@@ -1275,7 +1274,7 @@ export function EventModal({
)}
{canEditBody && (
-
setMode("edit")}>
+ setMode("edit")}>
{t("events.edit")}
@@ -1301,11 +1300,11 @@ export function EventModal({
setTitle(e.target.value)}
placeholder={t("form.title")}
maxLength={500}
- autoFocus
/>
diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx
index 7d7628b08..fdd4aff31 100644
--- a/components/calendar/ical-import-modal.tsx
+++ b/components/calendar/ical-import-modal.tsx
@@ -1,9 +1,11 @@
"use client";
-import { useState, useCallback, useRef, useEffect } from "react";
+import { useState, useCallback, useRef, useId } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
-import { X, Upload, Check, Loader2, RefreshCw, Globe } from "@/components/icons";
+import { Upload, Check, RefreshCw, Globe } from "@/components/icons";
+import { Dialog } from "@/components/ui/dialog";
+import { Spinner } from "@/components/ui/spinner";
import { format } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
@@ -31,7 +33,6 @@ type ImportMode = "file" | "url";
export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICalImportModalProps) {
const t = useTranslations("calendar.import");
const tCal = useTranslations("calendar");
- const tCommon = useTranslations("common");
const tForm = useTranslations("calendar.form");
const importEvents = useCalendarStore((s) => s.importEvents);
const timeFormat = useSettingsStore((s) => s.timeFormat);
@@ -50,7 +51,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
const [urlInput, setUrlInput] = useState(initialUrl || "");
const [isFetchingUrl, setIsFetchingUrl] = useState(false);
const fileInputRef = useRef(null);
- const modalRef = useRef(null);
+ const targetId = useId();
const validateFile = useCallback((file: File): string | null => {
if (file.size > MAX_FILE_SIZE) return t("file_too_large");
@@ -215,60 +216,36 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
}
};
- useEffect(() => {
- const handleKey = (e: KeyboardEvent) => {
- if (e.key === "Escape") onClose();
- };
- window.addEventListener("keydown", handleKey);
- return () => window.removeEventListener("keydown", handleKey);
- }, [onClose]);
-
- useEffect(() => {
- const modal = modalRef.current;
- if (!modal) return;
- const focusableEls = modal.querySelectorAll(
- 'input, select, textarea, button, [tabindex]:not([tabindex="-1"])'
- );
- const firstEl = focusableEls[0];
- const lastEl = focusableEls[focusableEls.length - 1];
-
- const handler = (e: KeyboardEvent) => {
- if (e.key !== "Tab") return;
- if (e.shiftKey && document.activeElement === firstEl) {
- e.preventDefault();
- lastEl?.focus();
- } else if (!e.shiftKey && document.activeElement === lastEl) {
- e.preventDefault();
- firstEl?.focus();
- }
- };
- modal.addEventListener("keydown", handler);
- firstEl?.focus();
- return () => modal.removeEventListener("keydown", handler);
- }, [step]);
+ // Nothing closes it while the events are being imported.
+ const close = useCallback(() => {
+ if (step !== "importing") onClose();
+ }, [step, onClose]);
return (
-
-
-
-
-
{t("title")}
-
-
-
-
-
-
+