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.
197 lines
5.7 KiB
TypeScript
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) */}
|
|
“{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>
|
|
</>
|
|
);
|
|
}
|