- 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>
209 lines
10 KiB
TypeScript
209 lines
10 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|
import { format } from 'date-fns';
|
|
|
|
interface SomedayList {
|
|
id: string;
|
|
title: string;
|
|
tasks: any[];
|
|
}
|
|
|
|
interface SearchModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
tasks: any[];
|
|
events: any[];
|
|
somedayLists?: SomedayList[];
|
|
onSelectTask: (date: Date) => void;
|
|
}
|
|
|
|
export default function SearchModal({ isOpen, onClose, tasks, events, somedayLists = [], onSelectTask }: SearchModalProps) {
|
|
const [query, setQuery] = useState('');
|
|
const [results, setResults] = useState<{ type: 'task' | 'event' | 'someday', item: any }[]>([]);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (isOpen && inputRef.current) {
|
|
inputRef.current.focus();
|
|
}
|
|
}, [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, input, a[href], [tabindex]:not([tabindex="-1"])')
|
|
).filter(el => !el.hasAttribute('disabled'));
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
if (!query.trim()) {
|
|
setResults([]);
|
|
return;
|
|
}
|
|
|
|
const lowerQuery = query.toLowerCase();
|
|
|
|
const filteredTasks = tasks.filter(t =>
|
|
t.title.toLowerCase().includes(lowerQuery) && !t.somedayListId
|
|
).map(t => ({ type: 'task' as const, item: t }));
|
|
|
|
// Include someday tasks from all someday lists
|
|
const somedayTasks = somedayLists.flatMap(list =>
|
|
list.tasks
|
|
.filter(t => t.title.toLowerCase().includes(lowerQuery))
|
|
.map(t => ({ type: 'someday' as const, item: { ...t, somedayListTitle: list.title } }))
|
|
);
|
|
|
|
const filteredEvents = events.filter(e =>
|
|
e.title.toLowerCase().includes(lowerQuery)
|
|
).map(e => ({ type: 'event' as const, item: e }));
|
|
|
|
setResults([...filteredTasks, ...somedayTasks, ...filteredEvents].slice(0, 15));
|
|
}, [query, tasks, events, somedayLists]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center pt-20"
|
|
onClick={onClose}
|
|
aria-hidden="true"
|
|
>
|
|
<div
|
|
ref={dialogRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Search tasks and events"
|
|
className="rounded-lg shadow-2xl w-[600px] max-w-[90%] overflow-hidden"
|
|
style={{ backgroundColor: 'var(--weekly-bg, #ffffff)' }}
|
|
onClick={e => e.stopPropagation()}
|
|
aria-hidden="false"
|
|
>
|
|
<div className="p-4 border-b border-gray-100 flex items-center gap-3">
|
|
<svg
|
|
width="20" height="20" viewBox="0 0 24 24" fill="none"
|
|
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
|
className="text-gray-400"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="11" cy="11" r="8"></circle>
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
</svg>
|
|
<input
|
|
ref={inputRef}
|
|
id="search-modal-input"
|
|
type="text"
|
|
placeholder="Search tasks and events..."
|
|
aria-label="Search tasks and events"
|
|
className="flex-1 text-lg outline-none text-gray-700 placeholder-gray-400"
|
|
value={query}
|
|
onChange={e => setQuery(e.target.value)}
|
|
onKeyDown={e => e.key === 'Escape' && onClose()}
|
|
/>
|
|
<button
|
|
onClick={onClose}
|
|
aria-label="Close search"
|
|
className="text-gray-400 hover:text-gray-600"
|
|
>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
className="max-h-[60vh] overflow-y-auto"
|
|
role="region"
|
|
aria-label="Search results"
|
|
aria-live="polite"
|
|
aria-atomic="false"
|
|
>
|
|
{results.length > 0 ? (
|
|
<ul className="py-2" role="list">
|
|
{results.map((result, idx) => (
|
|
<li key={`${result.type}-${result.item.id}-${idx}`} role="listitem">
|
|
<button
|
|
className="w-full px-4 py-3 hover:bg-gray-50 cursor-pointer flex items-center gap-3 border-b border-gray-50 last:border-0 text-left"
|
|
onClick={() => {
|
|
if (result.type === 'someday') {
|
|
onClose();
|
|
return;
|
|
}
|
|
const date = result.type === 'task'
|
|
? (result.item.scheduledDate ? new Date(result.item.scheduledDate) : new Date())
|
|
: (new Date(result.item.startTime));
|
|
onSelectTask(date);
|
|
onClose();
|
|
}}
|
|
aria-label={`${result.type === 'task' ? 'Task' : result.type === 'someday' ? 'Someday task' : 'Event'}: ${result.item.title}${result.item.completed ? ', completed' : ''}`}
|
|
>
|
|
<div
|
|
className={`p-2 rounded-full ${result.type === 'task' ? 'bg-blue-100 text-blue-600' : result.type === 'someday' ? 'bg-amber-100 text-amber-600' : 'bg-teal-100 text-teal-600'}`}
|
|
aria-hidden="true"
|
|
>
|
|
{result.type === 'task' || result.type === 'someday' ? (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"></path><path d="m9 12 2 2 4-4"></path></svg>
|
|
) : (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
|
)}
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="font-medium text-gray-800">{result.item.title}</div>
|
|
<div className="text-xs text-gray-500">
|
|
{result.type === 'someday'
|
|
? `Someday \u2022 ${result.item.somedayListTitle}`
|
|
: format(
|
|
result.type === 'task'
|
|
? (result.item.scheduledDate ? new Date(result.item.scheduledDate) : new Date())
|
|
: new Date(result.item.startTime),
|
|
'PPP'
|
|
)
|
|
}
|
|
</div>
|
|
</div>
|
|
{(result.type === 'task' || result.type === 'someday') && result.item.completed && (
|
|
<span className="text-xs bg-gray-100 text-gray-500 px-2 py-1 rounded">Completed</span>
|
|
)}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : query.trim() ? (
|
|
<p className="p-8 text-center text-gray-400" role="status">
|
|
No item found.
|
|
</p>
|
|
) : (
|
|
<p className="p-8 text-center text-gray-400">
|
|
Start typing to search...
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<style jsx>{`
|
|
.slide-in-from-top-2 { animation: slideIn 0.2s ease-out; }
|
|
@keyframes slideIn {
|
|
from { opacity: 0; transform: translateY(-10px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|