Files
familysync/apps/pwa/src/components/DeleteConfirmationDialog.tsx
T
Lucas Berger 40322e11bf 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
2026-06-05 18:46:24 -04:00

209 lines
6.7 KiB
TypeScript

/**
* 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<HTMLDivElement>(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 */}
<div
onClick={handleCancel}
style={{
position: 'fixed',
inset: 0,
background: 'var(--color-overlay)',
zIndex: 300,
}}
aria-hidden="true"
/>
{/* Dialog */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Delete event?"
tabIndex={-1}
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'var(--color-surface-raised)',
borderRadius: 'var(--space-2)',
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
padding: 'var(--space-6)',
width: '100%',
maxWidth: '320px',
zIndex: 301,
fontFamily: 'var(--font-family-base)',
}}
>
{/* Heading */}
<h2
style={{
margin: '0 0 var(--space-3) 0',
fontSize: 'var(--text-heading-size)',
fontWeight: 'var(--text-heading-weight)',
lineHeight: 'var(--text-heading-line-height)',
color: 'var(--color-text-primary)',
}}
>
{/* Plain text — XSS guard */}
Delete event?
</h2>
{/* Body */}
<p
style={{
margin: '0 0 var(--space-6) 0',
fontSize: 'var(--text-body-size)',
fontWeight: 'var(--text-body-weight)',
lineHeight: 'var(--text-body-line-height)',
color: 'var(--color-text-secondary)',
}}
>
{/* Plain text — XSS guard */}
This will be removed from your Fastmail calendar.
</p>
{/* Actions — flex row, right-aligned */}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: 'var(--space-3)',
alignItems: 'center',
}}
>
{/* Cancel — ghost style */}
<button
onClick={handleCancel}
disabled={mutation.isPending}
style={{
background: 'none',
border: 'none',
cursor: mutation.isPending ? 'not-allowed' : 'pointer',
minHeight: '44px',
padding: '0 var(--space-4)',
borderRadius: 'var(--space-1)',
fontSize: 'var(--text-label-size)',
fontWeight: 600,
color: 'var(--color-text-secondary)',
fontFamily: 'var(--font-family-base)',
}}
>
{/* Plain text — XSS guard */}
Cancel
</button>
{/* Delete — filled destructive style, 48px height (UI-SPEC) */}
<button
onClick={handleDelete}
disabled={mutation.isPending}
style={{
background: 'var(--color-destructive)',
border: 'none',
cursor: mutation.isPending ? 'not-allowed' : 'pointer',
minHeight: '48px',
padding: '0 var(--space-4)',
borderRadius: 'var(--space-1)',
fontSize: 'var(--text-label-size)',
fontWeight: 600,
color: '#ffffff',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-1)',
fontFamily: 'var(--font-family-base)',
}}
>
<Trash2 size={16} aria-hidden="true" />
{/* Plain text — XSS guard */}
Delete
</button>
</div>
</div>
</>
)
}