Files
familysync/apps/pwa/src/components/ListDeleteDialog.tsx
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
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.
2026-06-11 20:35:18 -04:00

197 lines
5.7 KiB
TypeScript

/**
* 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>
</>
);
}