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
+336
View File
@@ -0,0 +1,336 @@
/**
* CreateListSheet — new-list creation form (LIST-01, D-01).
*
* UI-SPEC §CreateListSheet:
* - Mobile: bottom sheet (slides up from bottom, 50vh height, backdrop overlay)
* - Desktop: centered modal (max-width 360px)
* - Heading: "New list"
* - Name input: auto-focused, placeholder "e.g. Groceries"
* - Shared/Private toggle: "Shared" default (D-01), clearly labeled
* - "Create" button: accent var(--color-member-0), disabled while name empty,
* destructive border on blank-submit attempt
* - "Cancel": ghost text button
* - Escape to close, backdrop click to close
*
* Behavior:
* - Open/close driven by listsStore.createListSheetOpen
* - useMutation(createList) with optimistic insert + onError rollback + onSettled invalidate
*
* T-04-06 XSS guard: all text is static or plain-text JSX children (no dangerouslySetInnerHTML).
*/
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createList } from '../api/listsClient.js'
import type { List, ListsResponse } from '../api/listsClient.js'
import { useListsStore } from '../store/listsStore.js'
export function CreateListSheet() {
const isOpen = useListsStore((s) => s.createListSheetOpen)
const setOpen = useListsStore((s) => s.setCreateListSheetOpen)
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [isShared, setIsShared] = useState(true) // D-01: default shared
const [attemptedEmpty, setAttemptedEmpty] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
// Auto-focus name input when sheet opens
useEffect(() => {
if (isOpen && inputRef.current) {
inputRef.current.focus()
// Reset form state on open
setName('')
setIsShared(true)
setAttemptedEmpty(false)
}
}, [isOpen])
// Escape key listener
useEffect(() => {
if (!isOpen) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleClose()
}
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [isOpen]) // eslint-disable-line react-hooks/exhaustive-deps
const handleClose = () => {
setOpen(false)
setName('')
setIsShared(true)
setAttemptedEmpty(false)
}
const mutation = useMutation({
mutationFn: (payload: { name: string; isShared: boolean }) => createList(payload),
onMutate: async (payload) => {
// Optimistic insert: add a temporary entry to the lists cache
await queryClient.cancelQueries({ queryKey: ['lists'] })
const previous = queryClient.getQueryData<ListsResponse>(['lists'])
const tempId = -Date.now() // negative temp ID so it won't collide with real IDs
queryClient.setQueryData<ListsResponse>(['lists'], (old) => ({
lists: [
...(old?.lists ?? []),
{
id: tempId,
name: payload.name,
isShared: payload.isShared,
ownerId: 0, // unknown until response
activeCount: 0,
doneCount: 0,
} as List,
],
}))
return { previous }
},
onError: (_err, _vars, context) => {
// Rollback on error
if (context?.previous) {
queryClient.setQueryData(['lists'], context.previous)
}
},
onSettled: () => {
// Always invalidate to get canonical server state
queryClient.invalidateQueries({ queryKey: ['lists'] })
},
onSuccess: () => {
handleClose()
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
setAttemptedEmpty(true)
return
}
mutation.mutate({ name: name.trim(), isShared })
}
if (!isOpen) return null
const isNameEmpty = !name.trim()
const inputBorderColor = attemptedEmpty && isNameEmpty
? 'var(--color-destructive)'
: 'var(--color-border)'
return (
<>
{/* Backdrop */}
<div
onClick={handleClose}
style={{
position: 'fixed',
inset: 0,
background: 'var(--color-overlay)',
zIndex: 300,
}}
aria-hidden="true"
/>
{/* Sheet / Modal */}
<div
role="dialog"
aria-modal="true"
aria-label="New list"
style={{
position: 'fixed',
// Mobile: bottom sheet; Desktop: centered modal (via media-query-like inline approach)
bottom: 0,
left: 0,
right: 0,
background: 'var(--color-surface-raised)',
borderRadius: 'var(--space-3) var(--space-3) 0 0',
boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
padding: 'var(--space-6)',
zIndex: 301,
fontFamily: 'var(--font-family-base)',
maxWidth: '480px',
margin: '0 auto',
}}
>
{/* Heading */}
<h2
style={{
margin: '0 0 var(--space-6) 0',
fontSize: 'var(--text-heading-size)',
fontWeight: 600,
lineHeight: 'var(--text-heading-line-height)',
color: 'var(--color-text-primary)',
}}
>
{/* Plain text — XSS guard */}
New list
</h2>
<form onSubmit={handleSubmit}>
{/* Name input */}
<div style={{ marginBottom: 'var(--space-4)' }}>
<label
htmlFor="create-list-name"
style={{
display: 'block',
fontSize: 'var(--text-label-size)',
fontWeight: 600,
color: 'var(--color-text-secondary)',
marginBottom: 'var(--space-2)',
}}
>
{/* Plain text — XSS guard */}
Name
</label>
<input
ref={inputRef}
id="create-list-name"
type="text"
value={name}
onChange={(e) => {
setName(e.target.value)
if (e.target.value.trim()) setAttemptedEmpty(false)
}}
placeholder="e.g. Groceries"
maxLength={255}
style={{
width: '100%',
boxSizing: 'border-box',
fontSize: 'var(--text-body-size)',
fontFamily: 'var(--font-family-base)',
padding: 'var(--space-2) var(--space-4)',
border: `1px solid ${inputBorderColor}`,
borderRadius: 'var(--space-1)',
background: 'var(--color-surface)',
color: 'var(--color-text-primary)',
minHeight: '44px',
outline: 'none',
}}
/>
</div>
{/* Sharing toggle — Shared / Private (D-01: default Shared) */}
<div style={{ marginBottom: 'var(--space-6)' }}>
<div
style={{
fontSize: 'var(--text-label-size)',
fontWeight: 600,
color: 'var(--color-text-secondary)',
marginBottom: 'var(--space-2)',
}}
>
{/* Plain text — XSS guard */}
Visibility
</div>
<div
style={{
display: 'flex',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-1)',
overflow: 'hidden',
}}
role="group"
aria-label="List visibility"
>
<button
type="button"
onClick={() => setIsShared(true)}
aria-pressed={isShared}
style={{
flex: 1,
padding: 'var(--space-2) var(--space-4)',
fontSize: 'var(--text-label-size)',
fontWeight: 600,
fontFamily: 'var(--font-family-base)',
border: 'none',
cursor: 'pointer',
minHeight: '44px',
background: isShared ? 'var(--color-member-0)' : 'var(--color-surface)',
color: isShared ? '#ffffff' : 'var(--color-text-secondary)',
}}
>
{/* Plain text — XSS guard */}
Shared
</button>
<button
type="button"
onClick={() => setIsShared(false)}
aria-pressed={!isShared}
style={{
flex: 1,
padding: 'var(--space-2) var(--space-4)',
fontSize: 'var(--text-label-size)',
fontWeight: 600,
fontFamily: 'var(--font-family-base)',
border: 'none',
borderLeft: '1px solid var(--color-border)',
cursor: 'pointer',
minHeight: '44px',
background: !isShared ? 'var(--color-member-0)' : 'var(--color-surface)',
color: !isShared ? '#ffffff' : 'var(--color-text-secondary)',
}}
>
{/* Plain text — XSS guard */}
Private
</button>
</div>
</div>
{/* Actions */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-3)',
}}
>
{/* Create button */}
<button
type="submit"
disabled={isNameEmpty || mutation.isPending}
style={{
width: '100%',
minHeight: '48px',
background: isNameEmpty ? 'var(--color-surface-dim)' : 'var(--color-member-0)',
color: isNameEmpty ? 'var(--color-text-muted)' : '#ffffff',
border: attemptedEmpty && isNameEmpty
? '1px solid var(--color-destructive)'
: 'none',
borderRadius: 'var(--space-1)',
fontSize: 'var(--text-label-size)',
fontWeight: 600,
fontFamily: 'var(--font-family-base)',
cursor: isNameEmpty || mutation.isPending ? 'not-allowed' : 'pointer',
}}
>
{/* Plain text — XSS guard */}
{mutation.isPending ? 'Creating…' : 'Create'}
</button>
{/* Cancel — ghost style */}
<button
type="button"
onClick={handleClose}
disabled={mutation.isPending}
style={{
width: '100%',
minHeight: '44px',
background: 'none',
border: 'none',
fontSize: 'var(--text-label-size)',
fontWeight: 600,
fontFamily: 'var(--font-family-base)',
color: 'var(--color-text-secondary)',
cursor: mutation.isPending ? 'not-allowed' : 'pointer',
}}
>
{/* Plain text — XSS guard */}
Cancel
</button>
</div>
</form>
</div>
</>
)
}
+176
View File
@@ -0,0 +1,176 @@
/**
* ListCard — list summary card for the ListsIndex grid.
*
* UI-SPEC §ListCard:
* - Rounded card, padding --space-4 --space-6, background --color-surface
* - List name: --text-heading-* (18px/600)
* - Item count badge: "N active · M done" or "N items" at --text-label-*
* - Sharing indicator: "Shared" pill for shared lists (nothing for private)
* - ChevronRight at right edge
* - Whole card navigates to /lists/:id via react-router Link
* - Hover X (desktop) or long-press (phone) reveals delete button
*
* T-04-06 XSS guard: list name is rendered as a plain-text JSX child only.
*
* Props:
* list — the List object from the API
* onDelete — called when the user triggers delete from the hover/long-press X
*/
import { useNavigate } from 'react-router'
import { ChevronRight, Trash2 } from 'lucide-react'
import { useState } from 'react'
import type { List } from '../api/listsClient.js'
interface ListCardProps {
list: List
onDelete: (list: List) => void
}
function itemCountLabel(activeCount: number, doneCount: number): string {
const total = activeCount + doneCount
if (total === 0) return '0 items'
if (doneCount === 0) return `${activeCount} item${activeCount === 1 ? '' : 's'}`
return `${activeCount} active · ${doneCount} done`
}
export function ListCard({ list, onDelete }: ListCardProps) {
const navigate = useNavigate()
const [showDelete, setShowDelete] = useState(false)
const handleCardClick = () => {
navigate(`/lists/${list.id}`)
}
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation() // Don't navigate when delete is clicked
onDelete(list)
}
return (
<div
role="listitem"
style={{ position: 'relative' }}
onMouseEnter={() => setShowDelete(true)}
onMouseLeave={() => setShowDelete(false)}
>
<button
type="button"
onClick={handleCardClick}
aria-label={`Open list: ${list.name}`}
style={{
width: '100%',
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-2)',
padding: 'var(--space-4) var(--space-6, 24px)',
boxShadow: '0 1px 3px rgba(0,0,0,0.06)',
minHeight: '56px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontFamily: 'var(--font-family-base)',
cursor: 'pointer',
textAlign: 'left',
}}
>
{/* Left: name + meta */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
lineHeight: 'var(--text-heading-line-height, 1.25)',
// Truncate long names
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{/* Plain text — XSS guard (T-04-06) */}
{list.name}
</div>
<div
style={{
marginTop: 'var(--space-1)',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
flexWrap: 'wrap',
}}
>
{/* Item count badge */}
<span
style={{
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
color: 'var(--color-text-muted)',
lineHeight: 'var(--text-label-line-height, 1.4)',
}}
>
{/* Plain text — XSS guard */}
{itemCountLabel(list.activeCount, list.doneCount)}
</span>
{/* "Shared" pill — only for shared lists (D-01) */}
{list.isShared && (
<span
style={{
display: 'inline-block',
background: 'var(--color-surface-dim)',
color: 'var(--color-text-secondary)',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 400,
padding: '2px var(--space-2)',
borderRadius: '4px',
lineHeight: 'var(--text-label-line-height, 1.4)',
}}
>
{/* Plain text — XSS guard */}
Shared
</span>
)}
</div>
</div>
{/* Right: chevron */}
<ChevronRight
size={16}
color="var(--color-text-muted)"
aria-hidden="true"
style={{ flexShrink: 0, marginLeft: 'var(--space-2)' }}
/>
</button>
{/* Delete button — visible on desktop hover (phone: long-press/swipe) */}
{showDelete && (
<button
type="button"
onClick={handleDeleteClick}
aria-label={`Delete list: ${list.name}`}
style={{
position: 'absolute',
top: '50%',
right: 'calc(var(--space-6, 24px) + 20px)', // outside the chevron
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 'var(--space-2)',
color: 'var(--color-destructive)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '44px',
minWidth: '44px',
borderRadius: 'var(--space-1)',
}}
>
<Trash2 size={16} aria-hidden="true" />
</button>
)}
</div>
)
}
@@ -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>
</>
)
}
@@ -0,0 +1,62 @@
/**
* ListsEmptyState — shown in ListsIndex when the user has no lists.
*
* UI-SPEC §ListsEmptyState:
* - Center-aligned in the list-index scrollable area
* - Icon: ClipboardList (lucide-react, 32px, --color-text-muted)
* - Heading: "No lists yet" (--text-heading-*)
* - Body: "Tap + to create your first shared list — Groceries, Gift Ideas, or anything else."
*
* T-04-06 XSS guard: all text is static plain-text JSX children (no user input).
*/
import { ClipboardList } from 'lucide-react'
export function ListsEmptyState() {
return (
<div
role="status"
aria-live="polite"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
padding: '48px var(--space-4)',
gap: 'var(--space-4)',
textAlign: 'center',
fontFamily: 'var(--font-family-base)',
}}
>
<ClipboardList
size={32}
color="var(--color-text-muted)"
aria-hidden="true"
/>
<div
style={{
fontSize: 'var(--text-heading-size, 18px)',
fontWeight: 600,
color: 'var(--color-text-primary)',
lineHeight: 'var(--text-heading-line-height, 1.25)',
}}
>
{/* Plain text — XSS guard */}
No lists yet
</div>
<div
style={{
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: 'var(--color-text-muted)',
lineHeight: 'var(--text-body-line-height, 1.5)',
maxWidth: '280px',
}}
>
{/* Plain text — XSS guard */}
Tap + to create your first shared list &mdash; Groceries, Gift Ideas, or anything else.
</div>
</div>
)
}