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:
Lucas Berger
2026-06-09 12:45:19 -04:00
parent 9546b747d2
commit 95dbc663c1
6 changed files with 998 additions and 209 deletions
@@ -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) */}
&ldquo;{list.name}&rdquo; 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>
</>
)
}