feat(calendar): open a clicked event as a card with its actions on top

On wide screens a click on an event now opens its card next to it
instead of the side panel. The card stays until it is closed and has
edit, delete, more and close buttons at the top, a square in the
event's colour beside the title, and a line naming the calendar.
More opens the same menu as a right-click (duplicate, export, copy).
Delete asks for confirmation inside the card, and edit goes straight
to the form. Hover previews still work as before while no card is
open. Phones keep opening the full editor.
This commit is contained in:
Linus Rath
2026-10-10 23:03:08 +02:00
parent d5ce67e167
commit 2dd90a7b5c
3 changed files with 139 additions and 106 deletions
+37 -12
View File
@@ -189,11 +189,17 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const [pendingScopeAction, setPendingScopeAction] = useState<PendingScopeAction | null>(null);
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null);
const [detailAnchorRect, setDetailAnchorRect] = useState<DOMRect | null>(null);
// A clicked event's card stays until closed; a hover preview goes when
// the pointer leaves. Held in a ref too for the hover timers.
const [detailPinned, setDetailPinned] = useState(false);
const detailPinnedRef = useRef(false);
useEffect(() => { detailPinnedRef.current = detailPinned; }, [detailPinned]);
const [pendingPreview, setPendingPreview] = useState<PendingEventPreview | null>(null);
// Quick create (#calendar redesign): an empty slot clicked in the week or
// day view opens a small editor beside a placeholder block.
const [quickDraft, setQuickDraft] = useState<QuickCreateDraft | null>(null);
const [defaultModalTitle, setDefaultModalTitle] = useState<string | undefined>();
const [modalStartsInEdit, setModalStartsInEdit] = useState(false);
const [showTaskModal, setShowTaskModal] = useState(false);
const [editTask, setEditTask] = useState<import("@/lib/jmap/types").CalendarTask | null>(null);
const [mobileReturnToMonth, setMobileReturnToMonth] = useState(false);
@@ -618,7 +624,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
setShowEventModal(true);
}, [selectedDate, setSelectedDate]);
const openEditModal = useCallback((event: CalendarEvent) => {
const openEditModal = useCallback((event: CalendarEvent, startInForm = false) => {
setModalStartsInEdit(startInForm);
setEditEvent(event);
setDefaultModalDate(undefined);
setShowTaskModal(false);
@@ -675,15 +682,27 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const closeDetail = useCallback(() => {
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
detailPinnedRef.current = false;
setDetailPinned(false);
setDetailEvent(null);
setDetailAnchorRect(null);
}, []);
const handleSelectEvent = useCallback((event: CalendarEvent, _anchorRect: DOMRect) => {
// Click opens the sidebar for viewing/editing
closeDetail();
openEditModal(event);
}, [closeDetail, openEditModal]);
const handleSelectEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
// Phones open the full editor; wide screens show the event's card next
// to it, with edit and delete at the top (as in Google Calendar).
if (isMobile) {
closeDetail();
openEditModal(event);
return;
}
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
setQuickDraft(null);
detailPinnedRef.current = true;
setDetailPinned(true);
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
}, [isMobile, closeDetail, openEditModal]);
const {
contextMenu: eventContextMenu,
@@ -714,6 +733,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const handleHoverEvent = useCallback((event: CalendarEvent, anchorRect: DOMRect) => {
if (isMobile) return;
if (detailPinnedRef.current) return;
if (calendarHoverPreview === 'off') return;
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
// Don't show hover popover if the sidebar is already open for this event
@@ -732,7 +752,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const handleHoverLeave = useCallback(() => {
if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; }
if (detailPinnedRef.current) return;
hoverTimerRef.current = setTimeout(() => {
if (detailPinnedRef.current) return;
setDetailEvent(null);
setDetailAnchorRect(null);
}, 300);
@@ -822,7 +844,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
if (detailEvent) {
const ev = detailEvent;
closeDetail();
openEditModal(ev);
// The pencil on the event card goes straight to the form.
openEditModal(ev, true);
}
}, [detailEvent, closeDetail, openEditModal]);
@@ -983,8 +1006,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
setEditEvent(null);
setShowTaskModal(false);
setEditTask(null);
setDetailEvent(null);
setDetailAnchorRect(null);
closeDetail();
setQuickDraft((prev) => ({
start,
end: finalEnd,
@@ -993,7 +1015,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
? prev.calendarId
: preferred?.id ?? "",
}));
}, [isMobile, openCreateModal, writableCalendars]);
}, [isMobile, openCreateModal, writableCalendars, closeDetail]);
const closeQuickCreate = useCallback(() => setQuickDraft(null), []);
@@ -1883,13 +1905,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
{!isMobile && showEventModal && (
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
<EventModal
key={editEvent?.id ?? 'new'}
key={`${editEvent?.id ?? 'new'}${modalStartsInEdit ? ':edit' : ''}`}
event={editEvent}
calendars={displayCalendars}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
defaultAllDay={defaultModalAllDay}
defaultTitle={defaultModalTitle}
startInEditMode={modalStartsInEdit}
defaultCalendarId={defaultCalendarIdForCreate}
onSave={handleSaveEvent}
onDelete={handleDeleteEvent}
@@ -2018,6 +2041,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onEdit={handleEditFromDetail}
onDelete={handleDeleteFromDetail}
onDuplicate={handleDuplicateFromDetail}
onMore={(e) => handleContextMenuEvent(e, detailEvent)}
onClose={closeDetail}
onSaveNote={handleSaveNoteFromDetail}
onRsvp={handleRsvpFromDetail}
@@ -2032,13 +2056,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
{showEventModal && isMobile && (
<EventModal
key={editEvent?.id ?? 'new'}
key={`${editEvent?.id ?? 'new'}${modalStartsInEdit ? ':edit' : ''}`}
event={editEvent}
calendars={displayCalendars}
defaultDate={defaultModalDate}
defaultEndDate={defaultModalEndDate}
defaultAllDay={defaultModalAllDay}
defaultTitle={defaultModalTitle}
startInEditMode={modalStartsInEdit}
defaultCalendarId={defaultCalendarIdForCreate}
onSave={handleSaveEvent}
onDelete={handleDeleteEvent}
+98 -93
View File
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
import { LinkifiedText } from "@/components/ui/linkified-text";
import {
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
Pencil, Trash2, Copy, Send, Check,
Pencil, Trash2, Copy, Send, Check, MoreVertical, Calendar as CalendarIcon,
} from "@/components/icons";
import { format, isSameDay } from "date-fns";
import { cn } from "@/lib/utils";
@@ -32,6 +32,8 @@ interface EventDetailPopoverProps {
onEdit: () => void;
onDelete: () => void;
onDuplicate: () => void;
/** Opens the event's full action menu (export, copy, ...) at the click. */
onMore?: (e: React.MouseEvent) => void;
onClose: () => void;
onSaveNote: (note: string) => void;
onRsvp?: (status: CalendarParticipant["participationStatus"]) => void;
@@ -44,6 +46,7 @@ interface EventDetailPopoverProps {
}
const POPOVER_WIDTH = 360;
const HEADER_BUTTON = "p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground";
const POPOVER_GAP = 8;
const VIEWPORT_MARGIN = 12;
const MAX_HEIGHT = 480;
@@ -118,6 +121,7 @@ export function EventDetailPopover({
onEdit,
onDelete,
onDuplicate,
onMore,
onClose,
onSaveNote,
onRsvp,
@@ -324,49 +328,99 @@ export function EventDetailPopover({
visibility: position ? "visible" : "hidden",
}}
>
{/* Color accent bar */}
<div className="h-1 w-full" style={{ backgroundColor: color }} />
{/* Header */}
<div className="flex items-start justify-between gap-2 px-4 pt-3 pb-1">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: color }}
/>
<h3 className={cn(
"text-base font-semibold truncate text-foreground",
event.status === "cancelled" && "line-through text-muted-foreground"
)}>
{event.title || t("events.no_title")}
</h3>
</div>
{calendar && (
<p className="text-xs text-muted-foreground mt-0.5 ps-[18px]">
{calendar.name}
{event.status === "tentative" && (
<span className="ms-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
{t("detail.tentative")}
</span>
)}
{event.status === "cancelled" && (
<span className="ms-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 line-through">
{t("detail.cancelled")}
</span>
)}
</p>
)}
</div>
{/* Actions sit at the top, the event's colour square beside its title. */}
<div className="flex items-center justify-end gap-0.5 px-2 pt-2">
{canEditBody && (
<button
type="button"
onClick={onEdit}
className={HEADER_BUTTON}
aria-label={t("events.edit")}
title={t("events.edit")}
>
<Pencil className="w-4 h-4" />
</button>
)}
{canEditBody && (
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className={HEADER_BUTTON}
aria-label={t("events.delete")}
title={t("events.delete")}
>
<Trash2 className="w-4 h-4" />
</button>
)}
<button
type="button"
onClick={onMore ?? onDuplicate}
className={HEADER_BUTTON}
aria-label={onMore ? t("toolbar.more") : t("events.duplicate")}
title={onMore ? t("toolbar.more") : t("events.duplicate")}
>
{onMore ? <MoreVertical className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 flex-shrink-0 mt-0.5 text-muted-foreground hover:text-foreground"
className={HEADER_BUTTON}
aria-label={t("form.cancel")}
>
<X className="w-4 h-4" />
</button>
</div>
{showDeleteConfirm && (
<div className="mx-4 mt-1 mb-2 flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2">
<span className="text-sm text-destructive flex-1">
{t("detail.delete_confirm")}
</span>
<Button
variant="destructive"
size="sm"
onClick={onDelete}
className="h-7 text-xs"
>
{t("events.delete")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDeleteConfirm(false)}
className="h-7 text-xs"
>
{t("form.cancel")}
</Button>
</div>
)}
<div className="flex items-start gap-2.5 px-4 pb-1">
<span
className="w-4 h-4 rounded-sm flex-shrink-0 mt-1"
style={{ backgroundColor: color }}
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<h3 className={cn(
"text-lg font-semibold leading-snug break-words text-foreground",
event.status === "cancelled" && "line-through text-muted-foreground"
)}>
{event.title || t("events.no_title")}
</h3>
{event.status === "tentative" && (
<span className="mt-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
{t("detail.tentative")}
</span>
)}
{event.status === "cancelled" && (
<span className="mt-1 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 line-through">
{t("detail.cancelled")}
</span>
)}
</div>
</div>
{/* Content */}
<div className={cn(
"px-4 py-2 space-y-2.5 overflow-y-auto",
@@ -523,6 +577,14 @@ export function EventDetailPopover({
</div>
)}
{/* Calendar */}
{calendar && (
<div className="flex items-start gap-2.5">
<CalendarIcon className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<span className="text-sm text-foreground truncate">{calendar.name}</span>
</div>
)}
{/* Description */}
{event.description && (
<div className="flex items-start gap-2.5">
@@ -634,63 +696,6 @@ export function EventDetailPopover({
</div>
)}
{/* Action Bar */}
<div className="px-4 py-2.5 border-t border-border flex items-center gap-1.5">
{showDeleteConfirm ? (
<div className="flex items-center gap-2 w-full">
<span className="text-sm text-destructive flex-1">
{t("form.delete_confirm")}
</span>
<Button
variant="outline"
size="sm"
onClick={onDelete}
className="text-red-600 dark:text-red-400 border-red-300 dark:border-red-700 h-7 text-xs"
>
{t("events.delete")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDeleteConfirm(false)}
className="h-7 text-xs"
>
{t("form.cancel")}
</Button>
</div>
) : (
<>
{canEditBody && (
<Button variant="default" size="sm" onClick={onEdit} className="h-7 text-xs">
<Pencil className="w-3.5 h-3.5 me-1" />
{t("events.edit")}
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={onDuplicate}
className="h-7 text-xs"
title={t("events.duplicate")}
>
<Copy className="w-3.5 h-3.5 me-1" />
{t("events.duplicate")}
</Button>
<div className="flex-1" />
{canEditBody && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="h-7 text-xs text-red-600 dark:text-red-400"
title={t("events.delete")}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
)}
</>
)}
</div>
</div>
);
+4 -1
View File
@@ -49,6 +49,8 @@ interface EventModalProps {
defaultDate?: Date;
defaultEndDate?: Date;
defaultAllDay?: boolean;
/** Open an existing event straight in the form instead of its summary. */
startInEditMode?: boolean;
/** Title to start a new event with (from the quick editor's "More options"). */
defaultTitle?: string;
defaultCalendarId?: string;
@@ -195,6 +197,7 @@ export function EventModal({
defaultEndDate,
defaultAllDay,
defaultTitle,
startInEditMode,
defaultCalendarId,
onSave,
onDelete,
@@ -220,7 +223,7 @@ export function EventModal({
: "fixed inset-0 z-50 flex flex-col bg-background";
const isEdit = !!event;
const formatEventDate = useFormatEventDate();
const [mode, setMode] = useState<"view" | "edit">(isEdit ? "view" : "edit");
const [mode, setMode] = useState<"view" | "edit">(isEdit && !startInEditMode ? "view" : "edit");
const userIsOrganizer = useMemo(() => {
if (!event) return true;