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
This commit is contained in:
@@ -47,6 +47,7 @@ import { buildCalendarConfig, SX_FIRST_DAY_OF_WEEK } from '../lib/calendarConfig
|
|||||||
import { useCalendarStore } from '../store/calendarStore.js'
|
import { useCalendarStore } from '../store/calendarStore.js'
|
||||||
import { EventDetailPopover } from './EventDetailPopover.js'
|
import { EventDetailPopover } from './EventDetailPopover.js'
|
||||||
import { EventForm } from './EventForm.js'
|
import { EventForm } from './EventForm.js'
|
||||||
|
import { DeleteConfirmationDialog } from './DeleteConfirmationDialog.js'
|
||||||
import { SyncStateToast } from './SyncStateToast.js'
|
import { SyncStateToast } from './SyncStateToast.js'
|
||||||
import { AppNav } from './AppNav.js'
|
import { AppNav } from './AppNav.js'
|
||||||
import { ColorLegend } from './ColorLegend.js'
|
import { ColorLegend } from './ColorLegend.js'
|
||||||
@@ -355,6 +356,9 @@ export function CalendarShell() {
|
|||||||
{/* EventForm modal — conditionally rendered while eventFormOpen */}
|
{/* EventForm modal — conditionally rendered while eventFormOpen */}
|
||||||
{eventFormOpen && <EventForm />}
|
{eventFormOpen && <EventForm />}
|
||||||
|
|
||||||
|
{/* DeleteConfirmationDialog — always mounted; renders nothing when deleteDialogOpen is false */}
|
||||||
|
<DeleteConfirmationDialog />
|
||||||
|
|
||||||
{/* SyncStateToast — always mounted; renders nothing when lastSyncedUid is null */}
|
{/* SyncStateToast — always mounted; renders nothing when lastSyncedUid is null */}
|
||||||
<SyncStateToast />
|
<SyncStateToast />
|
||||||
</div>
|
</div>
|
||||||
@@ -429,6 +433,9 @@ export function CalendarShell() {
|
|||||||
{/* EventForm modal — conditionally rendered while eventFormOpen */}
|
{/* EventForm modal — conditionally rendered while eventFormOpen */}
|
||||||
{eventFormOpen && <EventForm />}
|
{eventFormOpen && <EventForm />}
|
||||||
|
|
||||||
|
{/* DeleteConfirmationDialog — always mounted; renders nothing when deleteDialogOpen is false */}
|
||||||
|
<DeleteConfirmationDialog />
|
||||||
|
|
||||||
{/* SyncStateToast — always mounted; renders nothing when lastSyncedUid is null */}
|
{/* SyncStateToast — always mounted; renders nothing when lastSyncedUid is null */}
|
||||||
<SyncStateToast />
|
<SyncStateToast />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { MapPin } from 'lucide-react'
|
import { MapPin, Edit2, Trash2 } from 'lucide-react'
|
||||||
import { useCalendarStore } from '../store/calendarStore.js'
|
import { useCalendarStore } from '../store/calendarStore.js'
|
||||||
import type { CalendarOccurrence } from '../api/client.js'
|
import type { CalendarOccurrence } from '../api/client.js'
|
||||||
|
|
||||||
@@ -107,6 +107,8 @@ function formatDateTime(start: string, end: string, allDay: boolean): string {
|
|||||||
*/
|
*/
|
||||||
export function EventDetailPopover(props: ScheduleXEventModalProps = {}) {
|
export function EventDetailPopover(props: ScheduleXEventModalProps = {}) {
|
||||||
const { openEventId, setOpenEventId } = useCalendarStore()
|
const { openEventId, setOpenEventId } = useCalendarStore()
|
||||||
|
const setEventForm = useCalendarStore((s) => s.setEventForm)
|
||||||
|
const setDeleteDialog = useCalendarStore((s) => s.setDeleteDialog)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const dialogRef = useRef<HTMLDivElement>(null)
|
const dialogRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -377,14 +379,76 @@ export function EventDetailPopover(props: ScheduleXEventModalProps = {}) {
|
|||||||
: (occurrence.ownerName ?? occurrence.calendarName)}
|
: (occurrence.ownerName ?? occurrence.calendarName)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Phase 3 footer action area — Phase 3 adds edit/delete actions here (D-08) */}
|
{/* Phase 3 footer: Edit / Delete actions (D-10) */}
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
|
||||||
style={{
|
style={{
|
||||||
// Reserved: empty in Phase 2 (read-only); Phase 3 wires edit/delete buttons here
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
marginTop: 'var(--space-4)',
|
marginTop: 'var(--space-4)',
|
||||||
|
paddingTop: 'var(--space-3)',
|
||||||
|
borderTop: '1px solid var(--color-border-subtle)',
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{/* Edit button — ghost style, left-aligned */}
|
||||||
|
<button
|
||||||
|
aria-label="Edit event"
|
||||||
|
onClick={() => {
|
||||||
|
setEventForm(true, 'edit', occurrence.uid)
|
||||||
|
setOpenEventId(null)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
minWidth: '44px',
|
||||||
|
minHeight: '44px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 'var(--space-1)',
|
||||||
|
fontSize: 'var(--text-label-size)',
|
||||||
|
fontWeight: 'var(--text-label-weight)',
|
||||||
|
color: 'var(--color-text-primary)',
|
||||||
|
borderRadius: 'var(--space-1)',
|
||||||
|
padding: '0 var(--space-2)',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit2 size={16} aria-hidden="true" />
|
||||||
|
{/* Plain text — XSS guard */}
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Delete button — ghost style, right-aligned, destructive color */}
|
||||||
|
<button
|
||||||
|
aria-label="Delete event"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteDialog(true, occurrence.uid)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
minWidth: '44px',
|
||||||
|
minHeight: '44px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 'var(--space-1)',
|
||||||
|
fontSize: 'var(--text-label-size)',
|
||||||
|
fontWeight: 'var(--text-label-weight)',
|
||||||
|
color: 'var(--color-destructive)',
|
||||||
|
borderRadius: 'var(--space-1)',
|
||||||
|
padding: '0 var(--space-2)',
|
||||||
|
fontFamily: 'var(--font-family-base)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} aria-hidden="true" />
|
||||||
|
{/* Plain text — XSS guard */}
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user