fix: open the event editor in Title and give the import, subscription, new-calendar and webcal dialogs the standard dialog behaviour

This commit is contained in:
Linus Rath
2026-10-28 04:06:46 +01:00
parent d559c7cb20
commit d1bb44fe2e
6 changed files with 278 additions and 342 deletions
@@ -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(<EventModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
expect(document.activeElement).toBe(screen.getByPlaceholderText('form.title'));
});
it('closes the import dialog with Escape, wherever the focus is', () => {
const onClose = vi.fn();
render(<ICalImportModal calendars={calendars} client={client} onClose={onClose} />);
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(<ICalSubscriptionModal client={client} onClose={onClose} />);
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(<CreateCalendarModal client={client} onClose={onClose} />);
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();
});
});
+10 -18
View File
@@ -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 ? (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={closeWebcalActionChoice} aria-hidden="true" />
<div
role="dialog"
aria-modal="true"
aria-label={tWebcalAction("title")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
>
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">{tWebcalAction("title")}</h2>
<p className="text-sm text-muted-foreground mt-1">{tWebcalAction("description", { name: pendingSubscription.name })}</p>
</div>
<div className="px-6 py-4 space-y-3">
<Dialog
isOpen
onClose={closeWebcalActionChoice}
title={tWebcalAction("title")}
description={tWebcalAction("description", { name: pendingSubscription.name })}
footer={<Button variant="outline" onClick={closeWebcalActionChoice}>{tWebcalAction("cancel")}</Button>}
>
<div className="pt-4 space-y-3">
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
<span className="text-start">
<span className="block font-medium">{tWebcalAction("import_title")}</span>
@@ -1712,11 +1708,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
</span>
</Button>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="ghost" onClick={closeWebcalActionChoice}>{tWebcalAction("cancel")}</Button>
</div>
</div>
</div>
</Dialog>
) : null;
if (!isAuthenticated) return null;
+49 -92
View File
@@ -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<CalendarKind>("events");
const [isSubmitting, setIsSubmitting] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const nameRef = useRef<HTMLInputElement>(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<HTMLElement>(
'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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-[1px]"
onClick={() => !isSubmitting && onClose()}
aria-hidden="true"
/>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("add_calendar")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<CalendarIcon className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{t("add_calendar")}</h2>
</div>
<button
onClick={onClose}
disabled={isSubmitting}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground disabled:opacity-50"
aria-label={tCommon("close")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4">
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name")}
</label>
<input
type="text"
value={name}
onChange={(e) => 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(); }}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("color")}
</label>
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
</div>
<CalendarKindPicker value={kind} onChange={setKind} disabled={isSubmitting} />
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
<Dialog
isOpen
onClose={close}
title={t("add_calendar")}
dirty={isValid}
initialFocus={nameRef}
footer={
<>
<Button variant="outline" onClick={close} disabled={isSubmitting}>
{tCommon("cancel")}
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin me-2" />
<Spinner size="sm" className="me-2 text-current" />
{tCommon("loading")}
</>
) : (
t("create")
)}
</Button>
</>
}
>
<div className="space-y-4 pt-4">
<div>
<label htmlFor={nameId} className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name")}
</label>
<input
id={nameId}
ref={nameRef}
type="text"
value={name}
onChange={(e) => 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(); }}
/>
</div>
<div role="group" aria-labelledby={colorId}>
<span id={colorId} className="text-xs font-medium text-muted-foreground mb-1 block">
{t("color")}
</span>
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
</div>
<CalendarKindPicker value={kind} onChange={setKind} disabled={isSubmitting} />
</div>
</div>
</Dialog>
);
}
+26 -27
View File
@@ -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<HTMLDivElement>(null);
const titleRef = useRef<HTMLInputElement>(null);
const primaryRef = useRef<HTMLButtonElement>(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<HTMLElement>(
'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({
)}
</div>
{canEditBody && (
<Button onClick={() => setMode("edit")}>
<Button ref={primaryRef} onClick={() => setMode("edit")}>
<Pencil className="w-4 h-4 me-1" />
{t("events.edit")}
</Button>
@@ -1301,11 +1300,11 @@ export function EventModal({
<div>
<label className="text-sm font-medium mb-1 block">{t("form.title")}</label>
<Input
ref={titleRef}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("form.title")}
maxLength={500}
autoFocus
/>
</div>
+41 -80
View File
@@ -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<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(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<HTMLElement>(
'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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("title")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">{t("title")}</h2>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
aria-label={tCommon("close")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4">
<Dialog
isOpen
onClose={close}
title={t("title")}
size="full-on-phone"
dirty={step !== "select" || urlInput.trim() !== (initialUrl ?? "")}
footer={step !== "importing" ? (
<>
<Button variant="outline" onClick={close}>
{tForm("cancel")}
</Button>
{step === "preview" && (
<Button
onClick={handleImport}
disabled={selectedIndices.size === 0}
>
<Check className="w-4 h-4 me-1" />
{t("import_button")} ({selectedIndices.size})
</Button>
)}
</>
) : undefined}
>
<div className="pt-4 space-y-4">
{step === "select" && !isParsing && (
<>
<div className="flex border-b border-border mb-4">
@@ -334,6 +311,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
placeholder={t("url_placeholder")}
aria-label={t("tab_url")}
className="flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
onKeyDown={(e) => { if (e.key === "Enter") handleUrlFetch(); }}
/>
@@ -342,7 +320,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
disabled={!urlInput.trim() || isFetchingUrl}
>
{isFetchingUrl ? (
<Loader2 className="w-4 h-4 animate-spin" />
<Spinner size="sm" className="text-current" />
) : (
t("fetch")
)}
@@ -356,13 +334,13 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
{isParsing && (
<div className="flex flex-col items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-primary mb-3" />
<Spinner className="mb-3" />
<p className="text-sm text-muted-foreground">{t("parsing")}</p>
</div>
)}
{error && (
<div className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
<div role="alert" className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
{error}
</div>
)}
@@ -417,10 +395,11 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
{calendars.length > 1 && (
<div>
<label className="text-sm font-medium mb-1 block">
<label htmlFor={targetId} className="text-sm font-medium mb-1 block">
{t("target_calendar")}
</label>
<select
id={targetId}
value={calendarId}
onChange={(e) => setCalendarId(e.target.value)}
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"
@@ -438,29 +417,11 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
{step === "importing" && (
<div className="flex flex-col items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-primary mb-3" />
<Spinner className="mb-3" />
<p className="text-sm text-muted-foreground">{t("importing")}</p>
</div>
)}
</div>
{step !== "importing" && (
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="outline" onClick={onClose}>
{tForm("cancel")}
</Button>
{step === "preview" && (
<Button
onClick={handleImport}
disabled={selectedIndices.size === 0}
>
<Check className="w-4 h-4 me-1" />
{t("import_button")} ({selectedIndices.size})
</Button>
)}
</div>
)}
</div>
</div>
</Dialog>
);
}
+95 -125
View File
@@ -1,9 +1,10 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useState, useRef, useCallback, useId } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Loader2, Globe } 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, type ICalSubscription } from "@/stores/calendar-store";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
@@ -31,7 +32,9 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription, initi
const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
const urlRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null);
const id = useId();
const isValid = url.trim().length > 0 && name.trim().length > 0;
@@ -83,143 +86,110 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription, initi
}
}, [url, name, color, refreshInterval, client, isEdit, editSubscription, addICalSubscription, updateICalSubscription, onClose, t]);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose]);
// Nothing closes it while the feed is being fetched.
const close = useCallback(() => {
if (!isSubmitting) onClose();
}, [isSubmitting, onClose]);
const changed = isEdit
? url !== editSubscription!.url || name !== editSubscription!.name || color !== editSubscription!.color || refreshInterval !== editSubscription!.refreshInterval
: url.trim().length > 0 || name.trim().length > 0;
useEffect(() => {
const modal = modalRef.current;
if (!modal) return;
const focusableEls = modal.querySelectorAll<HTMLElement>(
'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 fieldClass = "w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring";
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onClose} aria-hidden="true" />
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-label={t("title")}
className="relative bg-background border border-border rounded-lg shadow-xl w-full max-w-md mx-4 animate-in zoom-in-95 duration-200"
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-primary" />
<h2 className="text-lg font-semibold">{isEdit ? t("edit_title") : t("title")}</h2>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
aria-label={tCommon("close")}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-4 space-y-4">
<p className="text-sm text-muted-foreground">{t("description")}</p>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("url_label")}
</label>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t("url_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(); }}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name_label")}
</label>
<input
type="text"
value={name}
onChange={(e) => 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(); }}
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("color_label")}
</label>
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("refresh_interval")}
</label>
<select
value={refreshInterval}
onChange={(e) => setRefreshInterval(Number(e.target.value))}
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}
>
<option value={15}>{t("interval_15")}</option>
<option value={30}>{t("interval_30")}</option>
<option value={60}>{t("interval_60")}</option>
<option value={360}>{t("interval_360")}</option>
<option value={1440}>{t("interval_1440")}</option>
</select>
</div>
{error && (
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 rounded-md px-3 py-2">
{error}
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-border">
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
<Dialog
isOpen
onClose={close}
title={isEdit ? t("edit_title") : t("title")}
description={t("description")}
dirty={changed}
// A link handed over from webcal:// already fills the address.
initialFocus={url ? nameRef : urlRef}
footer={
<>
<Button variant="outline" onClick={close} disabled={isSubmitting}>
{tCommon("cancel")}
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin me-2" />
<Spinner size="sm" className="me-2 text-current" />
{isEdit ? t("saving") : t("subscribing")}
</>
) : (
isEdit ? t("save") : t("subscribe")
)}
</Button>
</>
}
>
<div className="space-y-4 pt-4">
<div>
<label htmlFor={`${id}-url`} className="text-xs font-medium text-muted-foreground mb-1 block">
{t("url_label")}
</label>
<input
id={`${id}-url`}
ref={urlRef}
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder={t("url_placeholder")}
className={fieldClass}
disabled={isSubmitting}
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
/>
</div>
<div>
<label htmlFor={`${id}-name`} className="text-xs font-medium text-muted-foreground mb-1 block">
{t("name_label")}
</label>
<input
id={`${id}-name`}
ref={nameRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("name_placeholder")}
className={fieldClass}
disabled={isSubmitting}
onKeyDown={(e) => { if (e.key === "Enter" && isValid) handleSubmit(); }}
/>
</div>
<div role="group" aria-labelledby={`${id}-color`}>
<span id={`${id}-color`} className="text-xs font-medium text-muted-foreground mb-1 block">
{t("color_label")}
</span>
<CalendarColorPicker value={color} onChange={setColor} allowCustom />
</div>
<div>
<label htmlFor={`${id}-interval`} className="text-xs font-medium text-muted-foreground mb-1 block">
{t("refresh_interval")}
</label>
<select
id={`${id}-interval`}
value={refreshInterval}
onChange={(e) => setRefreshInterval(Number(e.target.value))}
className={fieldClass}
disabled={isSubmitting}
>
<option value={15}>{t("interval_15")}</option>
<option value={30}>{t("interval_30")}</option>
<option value={60}>{t("interval_60")}</option>
<option value={360}>{t("interval_360")}</option>
<option value={1440}>{t("interval_1440")}</option>
</select>
</div>
{error && (
<div role="alert" className="text-sm text-destructive bg-destructive/10 rounded-md px-3 py-2">
{error}
</div>
)}
</div>
</div>
</Dialog>
);
}