mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user