Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
/**
|
|
* 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<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,
|
|
},
|
|
],
|
|
}));
|
|
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 */}
|
|
<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>
|
|
</>
|
|
);
|
|
}
|