- Modals (SearchModal, ImportListModal, CalendarEventModal): role=dialog, aria-modal, aria-label, focus trap via keydown Tab handler, aria-hidden on backdrop - OnboardingWizard: role=dialog on card, aria-hidden on overlay, aria-current=step on progress dots wrapped in <nav><ol>, aria-label on nav buttons - GridTaskBlock: aria-label on all ~12 action buttons (complete, edit, add subtask, notes, rolling, recurrence, project, link, delete, move), aria-expanded/aria-pressed where applicable, aria-hidden on all decorative SVGs, aria-label on inline edit textarea and subtask inputs; note indicator div → button - Keyboard task move: new "Move Task" dialog in GridTaskBlock with date+time inputs, routed via onMoveTask prop → moveTaskToSlot in WeeklyView - WeeklyView: visually-hidden aria-live="polite" region with announce() helper; announces task add, complete/incomplete, delete, sync complete/failed - Forms: aria-invalid + aria-describedby + role=alert on error paragraphs in TaskForm; auth error div gets id + role=alert; email input gets aria-describedby; show/hide password button gets aria-label + aria-pressed - Spinners: role=status + aria-label on standalone spinners (TaskItem delete, WeeklyView sync); aria-hidden on inline button spinners (AuthForm, TaskForm, CalendarSettings, SettingsSidebar, EmailAuthForm) - layout.tsx: Open Graph and Twitter Card meta tags added v1.85.0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
219 lines
9.4 KiB
TypeScript
219 lines
9.4 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import { X } from 'lucide-react';
|
|
|
|
interface ImportListModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onImport: (selectedLists: { id: string, title: string }[]) => void;
|
|
provider: 'google' | 'apple' | 'outlook' | 'synology' | null;
|
|
lists: { id: string, title: string }[];
|
|
isLoading: boolean;
|
|
}
|
|
|
|
export const ImportListModal: React.FC<ImportListModalProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
onImport,
|
|
provider,
|
|
lists,
|
|
isLoading
|
|
}) => {
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
const firstFocusRef = useRef<HTMLButtonElement>(null);
|
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
|
|
const providerLabel = provider === 'google' ? 'Google Tasks'
|
|
: provider === 'outlook' ? 'Microsoft To-Do'
|
|
: 'Apple Reminders';
|
|
|
|
// Reset selection and focus first element when opening
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setSelectedIds([]);
|
|
setTimeout(() => firstFocusRef.current?.focus(), 50);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Focus trap
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') { onClose(); return; }
|
|
if (e.key !== 'Tab') return;
|
|
const dialog = dialogRef.current;
|
|
if (!dialog) return;
|
|
const focusable = Array.from(
|
|
dialog.querySelectorAll<HTMLElement>('button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])')
|
|
);
|
|
if (focusable.length === 0) return;
|
|
const first = focusable[0];
|
|
const last = focusable[focusable.length - 1];
|
|
if (e.shiftKey) {
|
|
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
} else {
|
|
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
}, [isOpen, onClose]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const toggleSelection = (id: string) => {
|
|
if (selectedIds.includes(id)) {
|
|
setSelectedIds(selectedIds.filter(lid => lid !== id));
|
|
} else {
|
|
setSelectedIds([...selectedIds, id]);
|
|
}
|
|
};
|
|
|
|
const handleSelectAll = () => {
|
|
if (selectedIds.length === lists.length) {
|
|
setSelectedIds([]);
|
|
} else {
|
|
setSelectedIds(lists.map(l => l.id));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
position: 'fixed',
|
|
top: 0, left: 0, right: 0, bottom: 0,
|
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
zIndex: 1000
|
|
}}
|
|
aria-hidden="true"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
ref={dialogRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="import-modal-title"
|
|
style={{
|
|
background: 'white',
|
|
padding: '2rem',
|
|
borderRadius: '8px',
|
|
width: '100%',
|
|
maxWidth: '500px',
|
|
maxHeight: '80vh',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '1rem',
|
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)'
|
|
}}
|
|
aria-hidden="false"
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<h2 id="import-modal-title" style={{ fontSize: '1.25rem', fontWeight: 600 }}>
|
|
Sync with {providerLabel}
|
|
</h2>
|
|
<button
|
|
ref={firstFocusRef}
|
|
onClick={onClose}
|
|
aria-label="Close dialog"
|
|
className="p-1 hover:bg-gray-100 rounded-full"
|
|
>
|
|
<X size={20} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
|
|
<div style={{ flex: 1, overflowY: 'auto', minHeight: '200px' }}>
|
|
{isLoading ? (
|
|
<div
|
|
role="status"
|
|
aria-label="Loading task lists"
|
|
aria-live="polite"
|
|
style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}
|
|
>
|
|
<div className="weekly-spinner" aria-hidden="true"></div>
|
|
<span style={{ marginLeft: '8px' }}>Loading lists...</span>
|
|
</div>
|
|
) : lists.length === 0 ? (
|
|
<p style={{ textAlign: 'center', color: '#666', marginTop: '2rem' }}>
|
|
No task lists found.
|
|
</p>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '8px' }}>
|
|
<button
|
|
onClick={handleSelectAll}
|
|
aria-label={selectedIds.length === lists.length ? 'Deselect all lists' : 'Select all lists'}
|
|
style={{ fontSize: '0.875rem', color: '#3b82f6', background: 'none', border: 'none', cursor: 'pointer' }}
|
|
>
|
|
{selectedIds.length === lists.length ? 'Deselect All' : 'Select All'}
|
|
</button>
|
|
</div>
|
|
<ul role="list" style={{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
{lists.map(list => (
|
|
<li key={list.id} role="listitem">
|
|
<label
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '10px',
|
|
padding: '8px',
|
|
borderRadius: '4px',
|
|
cursor: 'pointer',
|
|
backgroundColor: selectedIds.includes(list.id) ? '#eff6ff' : 'transparent'
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedIds.includes(list.id)}
|
|
onChange={() => toggleSelection(list.id)}
|
|
style={{ width: '16px', height: '16px' }}
|
|
/>
|
|
<span>{list.title}</span>
|
|
</label>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '1rem', marginTop: '1rem' }}>
|
|
<button
|
|
onClick={onClose}
|
|
style={{
|
|
padding: '8px 16px',
|
|
borderRadius: '4px',
|
|
border: '1px solid #e5e7eb',
|
|
background: 'white',
|
|
cursor: 'pointer'
|
|
}}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={() => onImport(lists.filter(l => selectedIds.includes(l.id)))}
|
|
disabled={selectedIds.length === 0 || isLoading}
|
|
aria-disabled={selectedIds.length === 0 || isLoading}
|
|
aria-label={`Sync ${selectedIds.length} selected list${selectedIds.length !== 1 ? 's' : ''} from ${providerLabel}`}
|
|
style={{
|
|
padding: '8px 16px',
|
|
borderRadius: '4px',
|
|
background: selectedIds.length === 0 || isLoading ? '#9ca3af' : '#2563eb',
|
|
color: 'white',
|
|
border: 'none',
|
|
cursor: selectedIds.length === 0 || isLoading ? 'not-allowed' : 'pointer',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px'
|
|
}}
|
|
>
|
|
{isLoading ? 'Syncing...' : 'Sync Selected'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|