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:
@@ -4,8 +4,8 @@
|
|||||||
* credentials: 'include' is required so the OIDC session cookie is sent with
|
* credentials: 'include' is required so the OIDC session cookie is sent with
|
||||||
* every request (same pattern as client.ts).
|
* every request (same pattern as client.ts).
|
||||||
*
|
*
|
||||||
* This file provides only the functions needed in Plan 04-01 (ListsIndex empty
|
* Exports: fetchLists, createList, patchList, deleteList
|
||||||
* state). Plans 04-02 and 04-03 expand it with createList, deleteList, item CRUD.
|
* Types: List, ListItem, ListsResponse
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const BASE = '/api'
|
const BASE = '/api'
|
||||||
@@ -26,6 +26,10 @@ export interface List {
|
|||||||
name: string
|
name: string
|
||||||
isShared: boolean
|
isShared: boolean
|
||||||
ownerId: number
|
ownerId: number
|
||||||
|
activeCount: number
|
||||||
|
doneCount: number
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListItem {
|
export interface ListItem {
|
||||||
@@ -45,3 +49,31 @@ export interface ListsResponse {
|
|||||||
export async function fetchLists(): Promise<ListsResponse> {
|
export async function fetchLists(): Promise<ListsResponse> {
|
||||||
return apiFetch('/lists').then((r) => r.json() as Promise<ListsResponse>)
|
return apiFetch('/lists').then((r) => r.json() as Promise<ListsResponse>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createList(payload: {
|
||||||
|
name: string
|
||||||
|
isShared: boolean
|
||||||
|
}): Promise<List> {
|
||||||
|
return apiFetch('/lists', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}).then((r) => r.json() as Promise<List>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchList(
|
||||||
|
id: number,
|
||||||
|
patch: { name?: string; isShared?: boolean },
|
||||||
|
): Promise<List> {
|
||||||
|
return apiFetch(`/lists/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
}).then((r) => r.json() as Promise<List>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteList(id: number): Promise<{ id: number }> {
|
||||||
|
return apiFetch(`/lists/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}).then((r) => r.json() as Promise<{ id: number }>)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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) */}
|
||||||
|
“{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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 — Groceries, Gift Ideas, or anything else.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,118 +3,42 @@
|
|||||||
*
|
*
|
||||||
* UI-SPEC §ListsIndex:
|
* UI-SPEC §ListsIndex:
|
||||||
* - Full-height scrollable column with 16px horizontal padding
|
* - Full-height scrollable column with 16px horizontal padding
|
||||||
* - "Lists" heading
|
* - "Lists" heading + FAB ("+ New List") in top-right corner
|
||||||
* - FAB ("+ New List") — fixed bottom-right on phone, top-right inline on desktop
|
|
||||||
* - Empty state: ListsEmptyState when no lists
|
|
||||||
* - State branches: isLoading → skeleton, isError → error+retry, data → list cards
|
* - State branches: isLoading → skeleton, isError → error+retry, data → list cards
|
||||||
|
* - Empty state: ListsEmptyState when no lists
|
||||||
|
* - ListCard for each list — navigates to /lists/:id on tap
|
||||||
|
* - ListDeleteDialog for delete confirmation (D-06)
|
||||||
|
* - CreateListSheet for new-list creation (D-01)
|
||||||
*
|
*
|
||||||
* Data flow:
|
* Data flow:
|
||||||
* TanStack Query ['lists'] → fetchLists → render list or empty state
|
* TanStack Query ['lists'] → fetchLists → render list cards or empty state
|
||||||
|
* useMutation(deleteList) → optimistic remove → onError rollback → onSettled invalidate
|
||||||
*
|
*
|
||||||
* Note: Create + delete logic landed in Plans 04-02/04-03.
|
* T-04-06 XSS guard: all user-supplied strings rendered via ListCard/ListDeleteDialog
|
||||||
* The FAB is a non-functional placeholder here (Plan 04-03 wires CreateListSheet).
|
* as plain-text JSX children — no dangerouslySetInnerHTML anywhere.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
import { fetchLists, type List } from '../api/listsClient.js'
|
import { fetchLists, deleteList } from '../api/listsClient.js'
|
||||||
|
import type { List, ListsResponse } from '../api/listsClient.js'
|
||||||
// ── Empty State ────────────────────────────────────────────────────────────
|
import { useListsStore } from '../store/listsStore.js'
|
||||||
|
import { ListCard } from '../components/ListCard.js'
|
||||||
function ListsEmptyState() {
|
import { ListDeleteDialog } from '../components/ListDeleteDialog.js'
|
||||||
return (
|
import { ListsEmptyState } from '../components/ListsEmptyState.js'
|
||||||
<div
|
import { CreateListSheet } from '../components/CreateListSheet.js'
|
||||||
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)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 'var(--text-heading-size, 18px)',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--color-text-primary)',
|
|
||||||
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
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',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Tap + to create your first shared list…
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── List Card (placeholder for Plan 04-03 full implementation) ─────────────
|
|
||||||
|
|
||||||
function ListCard({ list }: { list: List }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'var(--color-surface)',
|
|
||||||
border: '1px solid var(--color-border)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
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',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 'var(--text-heading-size, 18px)',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: 'var(--color-text-primary)',
|
|
||||||
lineHeight: 'var(--text-heading-line-height, 1.25)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{list.name}
|
|
||||||
</div>
|
|
||||||
{list.isShared && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: '4px',
|
|
||||||
display: 'inline-block',
|
|
||||||
background: 'var(--color-surface-dim)',
|
|
||||||
color: 'var(--color-text-muted)',
|
|
||||||
fontSize: 'var(--text-label-size, 13px)',
|
|
||||||
fontWeight: 400,
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Shared
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Main Component ─────────────────────────────────────────────────────────
|
// ── Main Component ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function ListsIndex() {
|
export function ListsIndex() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const setCreateListSheetOpen = useListsStore((s) => s.setCreateListSheetOpen)
|
||||||
|
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<List | null>(null)
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
queryKey: ['lists'],
|
queryKey: ['lists'],
|
||||||
queryFn: fetchLists,
|
queryFn: fetchLists,
|
||||||
@@ -124,7 +48,47 @@ export function ListsIndex() {
|
|||||||
|
|
||||||
const lists = data?.lists ?? []
|
const lists = data?.lists ?? []
|
||||||
|
|
||||||
|
// Delete mutation with optimistic removal
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (listId: number) => deleteList(listId),
|
||||||
|
onMutate: async (listId) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['lists'] })
|
||||||
|
const previous = queryClient.getQueryData<ListsResponse>(['lists'])
|
||||||
|
queryClient.setQueryData<ListsResponse>(['lists'], (old) => ({
|
||||||
|
lists: (old?.lists ?? []).filter((l) => l.id !== listId),
|
||||||
|
}))
|
||||||
|
return { previous }
|
||||||
|
},
|
||||||
|
onError: (_err, _vars, context) => {
|
||||||
|
if (context?.previous) {
|
||||||
|
queryClient.setQueryData(['lists'], context.previous)
|
||||||
|
}
|
||||||
|
// TODO: surface "Couldn't delete. Try again." toast (Plan 06 / notification layer)
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['lists'] })
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
navigate('/lists')
|
||||||
|
setDeleteTarget(null)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDeleteRequest = (list: List) => {
|
||||||
|
setDeleteTarget(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
deleteMutation.mutate(deleteTarget.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteClose = () => {
|
||||||
|
setDeleteTarget(null)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -155,13 +119,15 @@ export function ListsIndex() {
|
|||||||
lineHeight: 'var(--text-display-line-height, 1.2)',
|
lineHeight: 'var(--text-display-line-height, 1.2)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Plain text — XSS guard */}
|
||||||
Lists
|
Lists
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* FAB / inline button — non-functional placeholder until Plan 04-03 */}
|
{/* FAB / inline button — opens CreateListSheet */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="New list"
|
aria-label="New list"
|
||||||
|
onClick={() => setCreateListSheetOpen(true)}
|
||||||
style={{
|
style={{
|
||||||
width: '44px',
|
width: '44px',
|
||||||
height: '44px',
|
height: '44px',
|
||||||
@@ -182,6 +148,8 @@ export function ListsIndex() {
|
|||||||
|
|
||||||
{/* Content area */}
|
{/* Content area */}
|
||||||
<div
|
<div
|
||||||
|
role="list"
|
||||||
|
aria-live="polite"
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
@@ -200,6 +168,7 @@ export function ListsIndex() {
|
|||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Plain text — XSS guard */}
|
||||||
Loading lists…
|
Loading lists…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -221,6 +190,7 @@ export function ListsIndex() {
|
|||||||
fontSize: 'var(--text-body-size, 15px)',
|
fontSize: 'var(--text-body-size, 15px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Plain text — XSS guard */}
|
||||||
Could not load lists
|
Could not load lists
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -238,6 +208,7 @@ export function ListsIndex() {
|
|||||||
minHeight: '44px',
|
minHeight: '44px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Plain text — XSS guard */}
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -257,11 +228,27 @@ export function ListsIndex() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{lists.map((list) => (
|
{lists.map((list) => (
|
||||||
<ListCard key={list.id} list={list} />
|
<ListCard
|
||||||
|
key={list.id}
|
||||||
|
list={list}
|
||||||
|
onDelete={handleDeleteRequest}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation dialog (D-06) */}
|
||||||
|
<ListDeleteDialog
|
||||||
|
list={deleteTarget}
|
||||||
|
onClose={handleDeleteClose}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
isPending={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Create list sheet (D-01) */}
|
||||||
|
<CreateListSheet />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user