From 40322e11bfb2a8ed7279b08a66d238f17afb4ad6 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Fri, 5 Jun 2026 18:46:24 -0400 Subject: [PATCH] feat(03-06): wire EventDetailPopover Edit/Delete footer and implement DeleteConfirmationDialog - EventDetailPopover: replace aria-hidden placeholder with Edit2/Trash2 footer buttons - Edit opens EventForm in edit mode and closes popover - Delete opens DeleteConfirmationDialog via setDeleteDialog (T-03-17 two-tap) - DeleteConfirmationDialog: centered modal, max-width 320px, backdrop + focus trap - heading 'Delete event?', Fastmail body copy per UI-SPEC - Cancel/Escape close without deleting; Delete fires mutation - On success: setLastSyncedUid (feeds SyncStateToast), close dialog + popover - TanStack mutation; 48px Delete button (--color-destructive) - CalendarShell: mount DeleteConfirmationDialog in both phone and tablet/desktop layouts --- apps/pwa/src/components/CalendarShell.tsx | 7 + .../components/DeleteConfirmationDialog.tsx | 208 ++++++++++++++++++ .../pwa/src/components/EventDetailPopover.tsx | 74 ++++++- 3 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 apps/pwa/src/components/DeleteConfirmationDialog.tsx diff --git a/apps/pwa/src/components/CalendarShell.tsx b/apps/pwa/src/components/CalendarShell.tsx index 7350bf2..009f324 100644 --- a/apps/pwa/src/components/CalendarShell.tsx +++ b/apps/pwa/src/components/CalendarShell.tsx @@ -47,6 +47,7 @@ import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig import { useCalendarStore } from '../store/calendarStore.js' import { EventDetailPopover } from './EventDetailPopover.js' import { EventForm } from './EventForm.js' +import { DeleteConfirmationDialog } from './DeleteConfirmationDialog.js' import { SyncStateToast } from './SyncStateToast.js' import { AppNav } from './AppNav.js' import { ColorLegend } from './ColorLegend.js' @@ -355,6 +356,9 @@ export function CalendarShell() { {/* EventForm modal — conditionally rendered while eventFormOpen */} {eventFormOpen && } + {/* DeleteConfirmationDialog — always mounted; renders nothing when deleteDialogOpen is false */} + + {/* SyncStateToast — always mounted; renders nothing when lastSyncedUid is null */} @@ -429,6 +433,9 @@ export function CalendarShell() { {/* EventForm modal — conditionally rendered while eventFormOpen */} {eventFormOpen && } + {/* DeleteConfirmationDialog — always mounted; renders nothing when deleteDialogOpen is false */} + + {/* SyncStateToast — always mounted; renders nothing when lastSyncedUid is null */} diff --git a/apps/pwa/src/components/DeleteConfirmationDialog.tsx b/apps/pwa/src/components/DeleteConfirmationDialog.tsx new file mode 100644 index 0000000..a68952e --- /dev/null +++ b/apps/pwa/src/components/DeleteConfirmationDialog.tsx @@ -0,0 +1,208 @@ +/** + * DeleteConfirmationDialog — mandatory two-tap destructive delete confirmation (Plan 03-06). + * + * Threat T-03-17: All deletes require explicit confirmation. No single-tap inline delete. + * No "don't ask again". Every delete from the EventDetailPopover flows through this dialog. + * + * Trigger: DeleteConfirmationDialog opens when deleteDialogOpen (Zustand) is true. + * On confirm: calls deleteEvent(uid), sets lastSyncedUid → SyncStateToast tracks the outbox row. + * On cancel/Escape: closes dialog without deleting. Popover remains open. + * + * Layout: + * - Centered modal, max-width 320px, all breakpoints + * - Backdrop: --color-overlay + * - Focus trap while open + * - Escape to cancel + * + * Accessibility: + * - role="dialog", aria-modal="true" + * - Heading: "Delete event?" (18px/600) + * - Focus moves to dialog on open + * + * Security: T-03-15 — all text rendered as plain-text JSX children. + */ + +import { useEffect, useRef } from 'react' +import { useMutation } from '@tanstack/react-query' +import { Trash2 } from 'lucide-react' +import { useCalendarStore } from '../store/calendarStore.js' +import { deleteEvent } from '../api/client.js' + +// ── Component ────────────────────────────────────────────────────────────────── + +export function DeleteConfirmationDialog() { + const deleteDialogOpen = useCalendarStore((s) => s.deleteDialogOpen) + const deleteDialogUid = useCalendarStore((s) => s.deleteDialogUid) + const setDeleteDialog = useCalendarStore((s) => s.setDeleteDialog) + const setLastSyncedUid = useCalendarStore((s) => s.setLastSyncedUid) + const setOpenEventId = useCalendarStore((s) => s.setOpenEventId) + const dialogRef = useRef(null) + + // Focus trap — focus the dialog when it opens + useEffect(() => { + if (deleteDialogOpen && dialogRef.current) { + dialogRef.current.focus() + } + }, [deleteDialogOpen]) + + // Escape key listener — cancel without deleting (T-03-17: no accidental delete) + useEffect(() => { + if (!deleteDialogOpen) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + handleCancel() + } + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, [deleteDialogOpen]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleCancel = () => { + setDeleteDialog(false) + } + + // TanStack mutation — DELETE /api/events/:uid (enqueued to outbox, D-05) + const mutation = useMutation({ + mutationFn: (uid: string) => deleteEvent(uid), + onSuccess: () => { + // Wire sync-toast: set the UID so SyncStateToast starts polling (D-05/D-09) + if (deleteDialogUid) { + setLastSyncedUid(deleteDialogUid) + } + // Close both dialog and popover (per UI-SPEC delete interaction step 4) + setDeleteDialog(false) + setOpenEventId(null) + }, + }) + + const handleDelete = () => { + if (!deleteDialogUid) return + mutation.mutate(deleteDialogUid) + } + + // Nothing to show when closed + if (!deleteDialogOpen) return null + + return ( + <> + {/* Backdrop */} + - {/* Phase 3 footer action area — Phase 3 adds edit/delete actions here (D-08) */} + {/* Phase 3 footer: Edit / Delete actions (D-10) */} )