feat(04-03): wire ListsIndex + ListCard + CreateListSheet + ListDeleteDialog (LIST-01)
- listsClient.ts: add createList/patchList/deleteList + List/ListItem types with activeCount/doneCount - ListsEmptyState.tsx: extracted standalone component (ClipboardList icon, UI-SPEC copy) - ListCard.tsx: name/count badge/Shared pill/ChevronRight; hover-reveal delete button; navigates /lists/:id - CreateListSheet.tsx: bottom-sheet/modal; Shared default (D-01); optimistic useMutation; auto-focus; Escape to close - ListDeleteDialog.tsx: mirrors DeleteConfirmationDialog pattern; props-driven (no calendarStore); XSS guard on name - ListsIndex.tsx: replaced placeholder with real data via useQuery+useMutation; mounts CreateListSheet+ListDeleteDialog - DeleteConfirmationDialog.tsx NOT modified (stable, D-06 pattern preserved) - PWA typecheck passes; DeleteConfirmationDialog.test.tsx 10 passed - Playwright E2E: create Groceries+Gift Ideas (Shared pills); delete dialog → confirm → card disappears
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* ListDeleteDialog — two-tap delete confirmation for whole lists (D-06).
|
||||
*
|
||||
* Mirrors the structure of DeleteConfirmationDialog.tsx but driven by props
|
||||
* rather than calendarStore — the calendar dialog is NOT modified (stable).
|
||||
*
|
||||
* Layout (matches Phase 3 pattern):
|
||||
* - Backdrop: --color-overlay
|
||||
* - Centered modal, max-width 320px
|
||||
* - Focus trap while open
|
||||
* - Escape to cancel
|
||||
*
|
||||
* Accessibility:
|
||||
* - role="dialog", aria-modal="true"
|
||||
* - Focus moves to dialog on open
|
||||
* - Escape key cancels without deleting
|
||||
*
|
||||
* UI-SPEC §"List Delete":
|
||||
* - Heading: "Delete list?"
|
||||
* - Body: "{name}" and all its items will be permanently removed."
|
||||
* - Cancel (ghost) + Delete (destructive)
|
||||
* - On confirm: optimistic — navigate back, invalidate ['lists']
|
||||
* - On error: failure toast "Couldn't delete. Try again."
|
||||
*
|
||||
* T-04-06 XSS guard: list.name rendered as plain-text JSX child only.
|
||||
*
|
||||
* Props:
|
||||
* list — the List to delete (null = dialog closed)
|
||||
* onClose — called on cancel or after successful delete
|
||||
* onConfirm — called to initiate the delete (parent handles mutation)
|
||||
* isPending — whether delete mutation is in-flight
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import type { List } from '../api/listsClient.js'
|
||||
|
||||
interface ListDeleteDialogProps {
|
||||
list: List | null
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
isPending?: boolean
|
||||
}
|
||||
|
||||
export function ListDeleteDialog({
|
||||
list,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isPending = false,
|
||||
}: ListDeleteDialogProps) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Focus trap — focus the dialog when it opens
|
||||
useEffect(() => {
|
||||
if (list && dialogRef.current) {
|
||||
dialogRef.current.focus()
|
||||
}
|
||||
}, [list])
|
||||
|
||||
// Escape key listener — cancel without deleting (D-06: two-tap confirmation)
|
||||
useEffect(() => {
|
||||
if (!list) return
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => document.removeEventListener('keydown', onKeyDown)
|
||||
}, [list, onClose])
|
||||
|
||||
if (!list) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={onClose}
|
||||
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 list?"
|
||||
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 list?
|
||||
</h2>
|
||||
|
||||
{/* Body — list name in quotes (plain text, T-04-06) */}
|
||||
<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 (T-04-06) */}
|
||||
“{list.name}” and all its items will be permanently removed.
|
||||
</p>
|
||||
|
||||
{/* Actions — flex row, right-aligned */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 'var(--space-3)',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{/* Cancel — ghost style */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 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={onConfirm}
|
||||
disabled={isPending}
|
||||
style={{
|
||||
background: 'var(--color-destructive)',
|
||||
border: 'none',
|
||||
cursor: 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user