/** * 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 { 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(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(['lists']); const tempId = -Date.now(); // negative temp ID so it won't collide with real IDs queryClient.setQueryData(['lists'], (old) => ({ lists: [ ...(old?.lists ?? []), { id: tempId, name: payload.name, isShared: payload.isShared, ownerId: 0, // unknown until response activeCount: 0, doneCount: 0, }, ], })); 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; fire-and-forget (React Query handles cache update) void 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 */}