feat: comprehensive accessibility pass — ARIA, focus traps, keyboard move, live regions

- 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>
This commit is contained in:
mARTin 2026-04-05 18:18:08 +02:00
parent 04b72a326a
commit 5c01869444
14 changed files with 524 additions and 185 deletions

View File

@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
"version": "1.84.1",
"version": "1.85.0",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js",
"scripts": {

View File

@ -18,7 +18,19 @@ export const viewport: Viewport = {
export const metadata: Metadata = {
title: 'My Weekly To Do List',
description: 'A simple, designy to-do app.',
description: 'A simple, designy weekly task management app. Plan your week with a beautiful time-grid view.',
openGraph: {
title: 'My Weekly To Do List',
description: 'A simple, designy weekly task management app.',
url: 'https://todo.martin-bierschenk.de',
siteName: 'My Weekly To Do List',
type: 'website',
},
twitter: {
card: 'summary',
title: 'My Weekly To Do List',
description: 'A simple, designy weekly task management app.',
},
appleWebApp: {
capable: true,
statusBarStyle: 'default',

View File

@ -28,8 +28,8 @@ const AuthForm: React.FC<AuthFormProps> = ({ type, onSubmit, isLoading, error })
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-50 via-white to-blue-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8 bg-white rounded-2xl shadow-xl p-8 border border-gray-100 hover:shadow-2xl transition-shadow duration-300">
<div className="text-center">
<div className="mx-auto h-20 w-20 flex items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 mb-6 shadow-lg">
<svg xmlns="http://www.w3.org/2000/svg" className="h-10 w-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<div className="mx-auto h-20 w-20 flex items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-indigo-600 mb-6 shadow-lg" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" className="h-10 w-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
@ -44,10 +44,10 @@ const AuthForm: React.FC<AuthFormProps> = ({ type, onSubmit, isLoading, error })
</div>
{error && (
<div className="rounded-lg bg-red-50 p-4 border border-red-200 shadow-inner">
<div id="auth-error" role="alert" className="rounded-lg bg-red-50 p-4 border border-red-200 shadow-inner">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-6 w-6 text-red-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<div className="flex-shrink-0" aria-hidden="true">
<svg className="h-6 w-6 text-red-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
@ -66,8 +66,8 @@ const AuthForm: React.FC<AuthFormProps> = ({ type, onSubmit, isLoading, error })
Email address
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-gray-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
</svg>
@ -80,6 +80,8 @@ const AuthForm: React.FC<AuthFormProps> = ({ type, onSubmit, isLoading, error })
required
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!error}
aria-describedby={error ? "auth-error" : undefined}
className="appearance-none rounded-lg relative block w-full pl-14 px-4 py-4 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-base font-medium"
placeholder="Email address"
/>
@ -91,8 +93,8 @@ const AuthForm: React.FC<AuthFormProps> = ({ type, onSubmit, isLoading, error })
Password
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-gray-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
</svg>
</div>
@ -111,6 +113,8 @@ const AuthForm: React.FC<AuthFormProps> = ({ type, onSubmit, isLoading, error })
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? "Hide password" : "Show password"}
aria-pressed={showPassword}
className="text-gray-500 hover:text-gray-700 focus:outline-none transition-colors duration-200"
>
{showPassword ? (
@ -141,7 +145,7 @@ const AuthForm: React.FC<AuthFormProps> = ({ type, onSubmit, isLoading, error })
>
{isLoading ? (
<span className="flex items-center">
<div className="weekly-spinner weekly-spinner-white -ml-1 mr-3"></div>
<div className="weekly-spinner weekly-spinner-white -ml-1 mr-3" aria-hidden="true"></div>
Processing...
</span>
) : type === 'signup' ? (

View File

@ -165,6 +165,30 @@ export default function CalendarEventModal({
const [error, setError] = useState('');
const [isCalendarSelectorOpen, setIsCalendarSelectorOpen] = useState(false);
const calendarSelectorRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// Focus trap
useEffect(() => {
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]), textarea:not([disabled]), select:not([disabled]), a[href], [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);
}, [onClose]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@ -363,8 +387,16 @@ export default function CalendarEventModal({
const calColor = selectedCal?.backgroundColor || selectedCal?.color || '#3b82f6';
return (
<div className="weekly-modal-overlay" onClick={onClose}>
<div className="weekly-modal-content" onClick={e => e.stopPropagation()} style={{
<div className="weekly-modal-overlay" onClick={onClose} aria-hidden="true">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={title ? `${event ? 'Edit' : 'New'} event: ${title}` : (event ? 'Edit event' : 'New event')}
className="weekly-modal-content"
onClick={e => e.stopPropagation()}
aria-hidden="false"
style={{
maxWidth: '380px',
width: 'calc(100vw - 12px)',
padding: '0',
@ -391,6 +423,7 @@ export default function CalendarEventModal({
value={title}
onChange={e => setTitle(e.target.value)}
placeholder={language === 'de' ? 'Titel hinzufügen' : 'Add title'}
aria-label={language === 'de' ? 'Ereignistitel' : 'Event title'}
autoFocus
style={{
width: '100%', padding: '3px 0', fontSize: '1rem', fontWeight: 600,

View File

@ -248,7 +248,7 @@ const CalendarSettings: React.FC<CalendarSettingsProps> = ({
>
{isSyncing[connection.id] ? (
<>
<div className="weekly-spinner -ml-1 mr-2 inline-block"></div>
<div className="weekly-spinner -ml-1 mr-2 inline-block" aria-hidden="true"></div>
Syncing...
</>
) : (
@ -374,7 +374,7 @@ const CalendarSettings: React.FC<CalendarSettingsProps> = ({
>
{isConnectingApple ? (
<>
<div className="weekly-spinner weekly-spinner-white -ml-1 mr-2"></div>
<div className="weekly-spinner weekly-spinner-white -ml-1 mr-2" aria-hidden="true"></div>
Connecting...
</>
) : 'Connect'}
@ -424,7 +424,7 @@ const CalendarSettings: React.FC<CalendarSettingsProps> = ({
>
{isConnectingApple ? (
<>
<div className="weekly-spinner weekly-spinner-white -ml-1 mr-2"></div>
<div className="weekly-spinner weekly-spinner-white -ml-1 mr-2" aria-hidden="true"></div>
Verifying...
</>
) : 'Verify'}

View File

@ -43,6 +43,7 @@ interface GridTaskBlockProps {
onProjectAssign?: (taskId: string, projectId: string | null) => void;
kanbanStages?: KanbanStage[];
weatherEnabled?: boolean;
onMoveTask?: (taskId: string, targetDate: Date, targetTime: string) => void;
}
export function GridTaskBlock({
@ -78,7 +79,8 @@ export function GridTaskBlock({
projects,
onProjectAssign,
kanbanStages = [],
weatherEnabled = false
weatherEnabled = false,
onMoveTask,
}: GridTaskBlockProps) {
const [isNotesOpen, setIsNotesOpen] = useState(false);
const [notesValue, setNotesValue] = useState(task.markdownContent || "");
@ -89,9 +91,13 @@ export function GridTaskBlock({
const [isSubTaskInputOpen, setIsSubTaskInputOpen] = useState(false);
const [newSubTaskTitle, setNewSubTaskTitle] = useState("");
const [showProjectPicker, setShowProjectPicker] = useState(false);
const [showMoveDialog, setShowMoveDialog] = useState(false);
const [moveDate, setMoveDate] = useState('');
const [moveTime, setMoveTime] = useState('');
const notesRef = useRef<HTMLTextAreaElement>(null);
const subTaskInputRef = useRef<HTMLInputElement>(null);
const projectPickerRef = useRef<HTMLDivElement>(null);
const moveDialogRef = useRef<HTMLDivElement>(null);
// Touch: tap-to-reveal actions
const [touchActive, setTouchActive] = useState(false);
@ -289,6 +295,7 @@ export function GridTaskBlock({
>
<textarea
name="title"
aria-label={`Edit task title: ${task.title}`}
autoFocus
defaultValue={task.title}
onBlur={(e) => updateTask(task.id, e.target.value)}
@ -321,6 +328,7 @@ export function GridTaskBlock({
<input
type="checkbox"
checked={task.completed}
aria-label={task.completed ? `Mark "${task.title}" incomplete` : `Mark "${task.title}" complete`}
onChange={(e) => { e.stopPropagation(); toggleTask(task.id); }}
onClick={(e) => e.stopPropagation()}
className="task-checkbox flex-shrink-0"
@ -358,7 +366,8 @@ export function GridTaskBlock({
setIsSubTasksOpen(!isSubTasksOpen);
}}
className="subtask-indicator-badge"
title={expanded ? "Collapse subtasks" : `${completed}/${total} subtasks done`}
aria-label={expanded ? "Collapse subtasks" : `${completed} of ${total} subtasks done`}
aria-expanded={expanded}
style={{
display: "inline-flex",
alignItems: "center",
@ -377,7 +386,7 @@ export function GridTaskBlock({
transition: "all 0.15s",
}}
>
<ChevronDown size={12} style={{ transform: expanded ? "rotate(0deg)" : "rotate(-90deg)", transition: "transform 0.2s", flexShrink: 0 }} />
<ChevronDown size={12} aria-hidden="true" style={{ transform: expanded ? "rotate(0deg)" : "rotate(-90deg)", transition: "transform 0.2s", flexShrink: 0 }} />
<span style={{
display: "inline-block",
width: "36px",
@ -411,7 +420,7 @@ export function GridTaskBlock({
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
title={task.url}
aria-label={`Open link: ${task.url}`}
style={{
display: "inline-flex",
alignItems: "center",
@ -424,17 +433,18 @@ export function GridTaskBlock({
textDecoration: "none",
}}
>
<Link size={10} />
<Link size={10} aria-hidden="true" />
</a>
)}
{/* Note indicator */}
{task.markdownContent && task.markdownContent.trim().length > 0 && (
<div
<button
onClick={(e) => {
e.stopPropagation();
setIsNotesOpen(!isNotesOpen);
}}
title={isNotesOpen ? "Collapse note" : "Expand note"}
aria-label={isNotesOpen ? "Collapse note" : "Expand note"}
aria-expanded={isNotesOpen}
style={{
display: "inline-flex",
alignItems: "center",
@ -443,14 +453,15 @@ export function GridTaskBlock({
background: isNotesOpen ? "rgba(245, 158, 11, 0.12)" : "rgba(0,0,0,0.04)",
color: isNotesOpen ? "#f59e0b" : (darkMode ? "#888" : "#999"),
cursor: "pointer",
marginLeft: "3px"
marginLeft: "3px",
border: "none",
}}
>
<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
</div>
</button>
)}
{weatherEnabled && (() => {
const provider = task.externalProvider
@ -467,10 +478,10 @@ export function GridTaskBlock({
return (
<span
className="flex-shrink-0"
title={`Synced with ${info.label}`}
aria-label={`Synced with ${info.label}`}
style={{ display: "inline-flex", alignItems: "center", opacity: 0.55, marginLeft: "3px" }}
>
<FontAwesomeIcon icon={info.icon} style={{ width: 10, height: 10, color: info.color }} />
<FontAwesomeIcon icon={info.icon} aria-hidden="true" style={{ width: 10, height: 10, color: info.color }} />
</span>
);
})()}
@ -492,10 +503,10 @@ export function GridTaskBlock({
if (!info) return null;
return (
<span
title={`Synced with ${info.label}`}
aria-label={`Synced with ${info.label}`}
style={{ position: "absolute", top: "3px", right: "4px", display: "inline-flex", alignItems: "center", opacity: 0.45, zIndex: 2 }}
>
<FontAwesomeIcon icon={info.icon} style={{ width: 10, height: 10, color: info.color }} />
<FontAwesomeIcon icon={info.icon} aria-hidden="true" style={{ width: 10, height: 10, color: info.color }} />
</span>
);
})()}
@ -507,30 +518,40 @@ export function GridTaskBlock({
<button
className={`task-action-btn ${task.completed ? "active text-green-600 dark:text-green-500" : ""}`}
onClick={(e) => { e.stopPropagation(); toggleTask(task.id); }}
title={task.completed ? "Mark incomplete" : "Mark complete"}
aria-label={task.completed ? `Mark "${task.title}" incomplete` : `Mark "${task.title}" complete`}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="3" fill="none" strokeLinecap="round" strokeLinejoin="round">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="3" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<button
className="task-action-btn"
onClick={(e) => { e.stopPropagation(); setEditingTaskId(task.id); }}
title="Edit"
aria-label={`Edit "${task.title}"`}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button className={`task-action-btn ${isSubTaskInputOpen ? "active" : ""}`} onClick={(e) => { e.stopPropagation(); setIsSubTaskInputOpen(!isSubTaskInputOpen); if (!isSubTaskInputOpen) { setIsSubTasksOpen(true); setTimeout(() => subTaskInputRef.current?.focus(), 50); } }} title="Add sub-task">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<button
className={`task-action-btn ${isSubTaskInputOpen ? "active" : ""}`}
onClick={(e) => { e.stopPropagation(); setIsSubTaskInputOpen(!isSubTaskInputOpen); if (!isSubTaskInputOpen) { setIsSubTasksOpen(true); setTimeout(() => subTaskInputRef.current?.focus(), 50); } }}
aria-label="Add sub-task"
aria-expanded={isSubTaskInputOpen}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<button className={`task-action-btn ${isNotesOpen ? "active" : ""}`} onClick={(e) => { e.stopPropagation(); setIsNotesOpen(!isNotesOpen); }} title="Notes">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<button
className={`task-action-btn ${isNotesOpen ? "active" : ""}`}
onClick={(e) => { e.stopPropagation(); setIsNotesOpen(!isNotesOpen); }}
aria-label={isNotesOpen ? "Close notes" : "Open notes"}
aria-expanded={isNotesOpen}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="18" x2="21" y2="18" />
@ -540,9 +561,10 @@ export function GridTaskBlock({
<button
className={`task-action-btn ${task.isRolling ? "active" : ""}`}
onClick={(e) => { e.stopPropagation(); toggleTaskRolling(task.id); }}
title={task.isRolling ? "Disable rolling" : "Enable rolling"}
aria-label={task.isRolling ? "Disable rolling (task auto-rolls to next day)" : "Enable rolling (task auto-rolls to next day)"}
aria-pressed={task.isRolling}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
@ -552,9 +574,9 @@ export function GridTaskBlock({
<button
className={`task-action-btn ${task.isRecurring ? "active" : ""}`}
onClick={(e) => { e.stopPropagation(); setSelectedTaskForRecurrence(task); }}
title={task.isRecurring ? "Edit recurrence" : "Make recurring"}
aria-label={task.isRecurring ? "Edit recurrence" : "Make task recurring"}
>
<Repeat size={12} />
<Repeat size={12} aria-hidden="true" />
</button>
)}
@ -567,10 +589,13 @@ export function GridTaskBlock({
e.stopPropagation();
setShowProjectPicker(!showProjectPicker);
}}
title={task.project ? task.project.name : "Assign project"}
aria-label={task.project ? `Project: ${task.project.name} — change project` : "Assign to project"}
aria-expanded={showProjectPicker}
aria-haspopup="listbox"
>
<Circle
size={12}
aria-hidden="true"
fill={task.project?.color || "none"}
stroke={task.project?.color || "currentColor"}
strokeWidth={2}
@ -611,17 +636,49 @@ export function GridTaskBlock({
<button
className={`task-action-btn ${task.url || isUrlOpen ? "active" : ""}`}
onClick={(e) => { e.stopPropagation(); setIsUrlOpen(!isUrlOpen); }}
title={task.url ? task.url : "Add link"}
aria-label={task.url ? `Edit link: ${task.url}` : "Add link"}
aria-expanded={isUrlOpen}
style={task.url ? { color: "#2563eb" } : {}}
>
<Link size={12} />
<Link size={12} aria-hidden="true" />
</button>
<button className="task-action-btn delete" onClick={(e) => { e.stopPropagation(); deleteTask(task.id); }} title="Delete">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
<button
className="task-action-btn delete"
onClick={(e) => { e.stopPropagation(); deleteTask(task.id); }}
aria-label={`Delete "${task.title}"`}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
</button>
{onMoveTask && (
<button
className={`task-action-btn ${showMoveDialog ? "active" : ""}`}
onClick={(e) => {
e.stopPropagation();
if (!showMoveDialog) {
// Pre-fill with current date/time
const d = task.scheduledDate ? new Date(task.scheduledDate) : date;
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
setMoveDate(`${yyyy}-${mm}-${dd}`);
setMoveTime(task.startTime || '09:00');
}
setShowMoveDialog(!showMoveDialog);
}}
aria-label="Move task to different date/time"
aria-expanded={showMoveDialog}
>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="5 9 2 12 5 15" />
<polyline points="9 5 12 2 15 5" />
<line x1="2" y1="12" x2="22" y2="12" />
<line x1="12" y1="2" x2="12" y2="22" />
</svg>
</button>
)}
</div>
<div style={{ paddingLeft: "4px", paddingRight: "4px", paddingBottom: "10px", marginTop: "4px" }}>
@ -644,10 +701,11 @@ export function GridTaskBlock({
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<Link size={13} style={{ color: "#2563eb", flexShrink: 0 }} />
<Link size={13} aria-hidden="true" style={{ color: "#2563eb", flexShrink: 0 }} />
<input
ref={urlInputRef}
type="url"
aria-label="Task link URL"
value={urlValue}
onChange={(e) => setUrlValue(e.target.value)}
onKeyDown={(e) => {
@ -670,10 +728,10 @@ export function GridTaskBlock({
{urlValue && (
<button
onMouseDown={(e) => { e.preventDefault(); setUrlValue(""); updateTaskUrl(task.id, ""); setIsUrlOpen(false); }}
title="Remove link"
aria-label="Remove link"
style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: "#999" }}
>
<X size={12} />
<X size={12} aria-hidden="true" />
</button>
)}
{urlValue && (
@ -682,10 +740,10 @@ export function GridTaskBlock({
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
title="Open link"
aria-label="Open link in new tab"
style={{ color: "#2563eb", flexShrink: 0, display: "inline-flex" }}
>
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
@ -714,7 +772,7 @@ export function GridTaskBlock({
<button
onMouseDown={(e) => { e.preventDefault(); setIsNotesOpen(false); }}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: "1rem", lineHeight: 1, color: "#999", padding: "2px 4px" }}
title="Close"
aria-label="Close notes"
>×</button>
</div>
<div style={{ padding: "0 6px 8px" }}>
@ -734,11 +792,15 @@ export function GridTaskBlock({
<ul className="subtask-list mt-1" onClick={(e) => e.stopPropagation()}>
{task.subTasks.map((subTask: Task) => (
<li key={subTask.id} className={`subtask-item ${subTask.completed ? "completed" : ""}`}>
<button className="subtask-checkbox" onClick={() => toggleSubTask(subTask.id)}>
<button
className="subtask-checkbox"
onClick={() => toggleSubTask(subTask.id)}
aria-label={subTask.completed ? `Mark subtask "${subTask.title}" incomplete` : `Mark subtask "${subTask.title}" complete`}
>
{subTask.completed ? (
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12" /></svg>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12" /></svg>
) : (
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /></svg>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10" /></svg>
)}
</button>
{onSetEditingTaskId && editingTaskId === subTask.id ? (
@ -748,7 +810,7 @@ export function GridTaskBlock({
) : (
<span className={`subtask-title ${subTask.completed ? "completed" : ""}`} onClick={() => onSetEditingTaskId && onSetEditingTaskId(subTask.id)}>{subTask.title}</span>
)}
<button className="subtask-delete-btn" onClick={() => deleteSubTask(subTask.id)} title="Remove"><svg viewBox="0 0 24 24" width="10" height="10" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg></button>
<button className="subtask-delete-btn" onClick={() => deleteSubTask(subTask.id)} aria-label={`Remove subtask "${subTask.title}"`}><svg viewBox="0 0 24 24" width="10" height="10" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg></button>
</li>
))}
</ul>
@ -756,12 +818,87 @@ export function GridTaskBlock({
{isSubTaskInputOpen && (
<div className="subtask-add-row mt-1" onClick={(e) => e.stopPropagation()}>
<form onSubmit={(e) => { e.preventDefault(); if (newSubTaskTitle.trim()) { addSubTask(task.id, newSubTaskTitle.trim()); setNewSubTaskTitle(""); } }} style={{ display: "flex", alignItems: "center", gap: "0.25rem", flex: 1 }}>
<svg viewBox="0 0 24 24" width="12" height="12" stroke="var(--weekly-text-muted, #999)" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}><circle cx="12" cy="12" r="10" /></svg>
<input ref={subTaskInputRef} type="text" value={newSubTaskTitle} onChange={(e) => setNewSubTaskTitle(e.target.value)} onBlur={() => { if (!newSubTaskTitle.trim()) setIsSubTaskInputOpen(false); }} onKeyDown={(e) => { if (e.key === "Escape") { setNewSubTaskTitle(""); setIsSubTaskInputOpen(false); } }} placeholder="Add sub-task..." className="subtask-add-input" autoFocus />
<svg viewBox="0 0 24 24" width="12" height="12" stroke="var(--weekly-text-muted, #999)" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }} aria-hidden="true"><circle cx="12" cy="12" r="10" /></svg>
<input ref={subTaskInputRef} type="text" aria-label="New sub-task title" value={newSubTaskTitle} onChange={(e) => setNewSubTaskTitle(e.target.value)} onBlur={() => { if (!newSubTaskTitle.trim()) setIsSubTaskInputOpen(false); }} onKeyDown={(e) => { if (e.key === "Escape") { setNewSubTaskTitle(""); setIsSubTaskInputOpen(false); } }} placeholder="Add sub-task..." className="subtask-add-input" autoFocus />
</form>
</div>
)}
</div>
{showMoveDialog && onMoveTask && (
<div
ref={moveDialogRef}
role="dialog"
aria-modal="true"
aria-label={`Move task "${task.title}" to a new date and time`}
className="weekly-notes-popup"
onClick={(e) => e.stopPropagation()}
style={{
top: topOffset > 180 ? "auto" : "100%",
bottom: topOffset > 180 ? "100%" : "auto",
marginTop: topOffset > 180 ? "0" : "10px",
marginBottom: topOffset > 180 ? "10px" : "0",
left: "-10px",
right: "-10px",
padding: "10px 12px",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<p style={{ margin: 0, fontSize: "0.78rem", fontWeight: 600, color: "var(--weekly-text, #333)" }}>
Move: <em>{task.title}</em>
</p>
<div style={{ display: "flex", gap: "6px", alignItems: "center" }}>
<label htmlFor={`move-date-${task.id}`} style={{ fontSize: "0.75rem", whiteSpace: "nowrap" }}>Date</label>
<input
id={`move-date-${task.id}`}
type="date"
value={moveDate}
onChange={(e) => setMoveDate(e.target.value)}
autoFocus
style={{ flex: 1, fontSize: "0.78rem", border: "1px solid var(--weekly-border, #ddd)", borderRadius: "4px", padding: "3px 6px", background: "var(--weekly-bg, white)", color: "var(--weekly-text, #333)", outline: "none" }}
/>
</div>
<div style={{ display: "flex", gap: "6px", alignItems: "center" }}>
<label htmlFor={`move-time-${task.id}`} style={{ fontSize: "0.75rem", whiteSpace: "nowrap" }}>Time</label>
<input
id={`move-time-${task.id}`}
type="time"
value={moveTime}
onChange={(e) => setMoveTime(e.target.value)}
step="900"
style={{ flex: 1, fontSize: "0.78rem", border: "1px solid var(--weekly-border, #ddd)", borderRadius: "4px", padding: "3px 6px", background: "var(--weekly-bg, white)", color: "var(--weekly-text, #333)", outline: "none" }}
/>
</div>
<div style={{ display: "flex", gap: "6px", justifyContent: "flex-end" }}>
<button
onClick={() => setShowMoveDialog(false)}
style={{ fontSize: "0.75rem", padding: "3px 8px", borderRadius: "4px", border: "1px solid var(--weekly-border, #ddd)", background: "transparent", cursor: "pointer", color: "var(--weekly-text, #333)" }}
>
Cancel
</button>
<button
onClick={() => {
if (moveDate && moveTime) {
const [y, m, d] = moveDate.split('-').map(Number);
const targetDate = new Date(y, m - 1, d);
// Round time to nearest 15 min
const [h, min] = moveTime.split(':').map(Number);
const rounded = Math.round(min / 15) * 15;
const roundedMin = rounded === 60 ? 0 : rounded;
const roundedH = rounded === 60 ? h + 1 : h;
const roundedTime = `${String(roundedH).padStart(2, '0')}:${String(roundedMin).padStart(2, '0')}`;
onMoveTask(task.id, targetDate, roundedTime);
setShowMoveDialog(false);
}
}}
style={{ fontSize: "0.75rem", padding: "3px 8px", borderRadius: "4px", border: "none", background: "var(--weekly-accent, #6366f1)", color: "white", cursor: "pointer" }}
>
Move
</button>
</div>
</div>
)}
<div className="resize-handle" onMouseDown={onResizeStart} />
</div>
);

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { X, Check, Loader } from 'lucide-react';
import React, { useState, useEffect, useRef } from 'react';
import { X } from 'lucide-react';
interface ImportListModalProps {
isOpen: boolean;
@ -19,14 +19,45 @@ export const ImportListModal: React.FC<ImportListModalProps> = ({
isLoading
}) => {
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const firstFocusRef = useRef<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// Reset selection when opening
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) => {
@ -46,19 +77,25 @@ export const ImportListModal: React.FC<ImportListModalProps> = ({
};
return (
<div style={{
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
}}
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',
@ -69,20 +106,33 @@ export const ImportListModal: React.FC<ImportListModalProps> = ({
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 style={{ fontSize: '1.25rem', fontWeight: 600 }}>
Sync with {provider === 'google' ? 'Google Tasks' : provider === 'outlook' ? 'Microsoft To-Do' : 'Apple Reminders'}
<h2 id="import-modal-title" style={{ fontSize: '1.25rem', fontWeight: 600 }}>
Sync with {providerLabel}
</h2>
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded-full">
<X size={20} />
<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 style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<div className="weekly-spinner"></div>
<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 ? (
@ -94,14 +144,16 @@ export const ImportListModal: React.FC<ImportListModalProps> = ({
<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
key={list.id}
style={{
display: 'flex',
alignItems: 'center',
@ -120,7 +172,9 @@ export const ImportListModal: React.FC<ImportListModalProps> = ({
/>
<span>{list.title}</span>
</label>
</li>
))}
</ul>
</div>
)}
</div>
@ -141,6 +195,8 @@ export const ImportListModal: React.FC<ImportListModalProps> = ({
<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',

View File

@ -1079,14 +1079,18 @@ export default function OnboardingWizard({
};
return (
<div className="onboarding-overlay">
<div className="onboarding-overlay" aria-hidden="true">
<div
role="dialog"
aria-modal="true"
aria-label={`Setup wizard, step ${currentStep + 1} of ${STEPS.length}`}
className="onboarding-card"
style={{
background: bg, color: textColor,
maxWidth: isWideStep ? "1060px" : "560px",
transition: "max-width 0.3s ease",
}}
aria-hidden="false"
>
{/* Close button */}
{currentStep > 0 && (
@ -1096,9 +1100,9 @@ export default function OnboardingWizard({
position: "absolute", top: "12px", right: "12px", background: "none", border: "none",
color: mutedColor, cursor: "pointer", padding: "4px", zIndex: 10,
}}
title="Close"
aria-label="Close setup wizard"
>
<X size={18} />
<X size={18} aria-hidden="true" />
</button>
)}
@ -1113,15 +1117,17 @@ export default function OnboardingWizard({
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "16px 30px 24px", borderTop: `1px solid ${borderColor}`,
}}>
<button onClick={goBack} style={secondaryBtn}>
<ChevronLeft size={16} /> {t.back}
<button onClick={goBack} style={secondaryBtn} aria-label={t.back}>
<ChevronLeft size={16} aria-hidden="true" /> {t.back}
</button>
{/* Progress dots */}
<div style={{ display: "flex", gap: "6px" }}>
{STEPS.map((_, i) => (
<nav aria-label="Setup progress">
<ol style={{ display: "flex", gap: "6px", listStyle: "none", margin: 0, padding: 0 }}>
{STEPS.map((stepName, i) => (
<li key={i} aria-current={i === currentStep ? "step" : undefined}>
<div
key={i}
aria-label={`Step ${i + 1}: ${stepName}${i < currentStep ? " (completed)" : i === currentStep ? " (current)" : ""}`}
style={{
width: i === currentStep ? "20px" : "8px", height: "8px",
borderRadius: "4px", transition: "all 0.3s",
@ -1129,15 +1135,17 @@ export default function OnboardingWizard({
opacity: i < currentStep ? 0.5 : 1,
}}
/>
</li>
))}
</div>
</ol>
</nav>
{isLastStep ? (
<button onClick={handleFinish} style={primaryBtn}>
{t.finish}
</button>
) : (
<button onClick={goNext} style={primaryBtn}>
<button onClick={goNext} style={primaryBtn} aria-label={t.next}>
{t.next} <ChevronRight size={16} />
</button>
)}

View File

@ -21,6 +21,7 @@ export default function SearchModal({ isOpen, onClose, tasks, events, somedayLis
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) {
@ -28,6 +29,30 @@ export default function SearchModal({ isOpen, onClose, tasks, events, somedayLis
}
}, [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([]);
@ -57,34 +82,64 @@ export default function SearchModal({ isOpen, onClose, tasks, events, somedayLis
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center pt-20" onClick={onClose}>
<div className="rounded-lg shadow-2xl w-[600px] max-w-[90%] overflow-hidden" style={{ backgroundColor: 'var(--weekly-bg, #ffffff)' }} onClick={e => e.stopPropagation()}>
<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">
<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} 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"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
<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">
{results.length > 0 ? (
<div className="py-2">
{results.map((result, idx) => (
<div
key={`${result.type}-${result.item.id}-${idx}`}
className="px-4 py-3 hover:bg-gray-50 cursor-pointer flex items-center gap-3 border-b border-gray-50 last:border-0"
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();
@ -93,12 +148,15 @@ export default function SearchModal({ isOpen, onClose, tasks, events, somedayLis
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"
>
<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'}`}>
{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>
) : (
@ -122,17 +180,18 @@ export default function SearchModal({ isOpen, onClose, tasks, events, somedayLis
{(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>
)}
</div>
</button>
</li>
))}
</div>
</ul>
) : query.trim() ? (
<div className="p-8 text-center text-gray-400">
No Item found.
</div>
<p className="p-8 text-center text-gray-400" role="status">
No item found.
</p>
) : (
<div className="p-8 text-center text-gray-400">
<p className="p-8 text-center text-gray-400">
Start typing to search...
</div>
</p>
)}
</div>
</div>

View File

@ -4730,7 +4730,7 @@ function SettingsSidebar({
>
{isConnectingAppleCal ? (
<>
<div className="weekly-spinner weekly-spinner-white -ml-1 mr-2"></div>
<div className="weekly-spinner weekly-spinner-white -ml-1 mr-2" aria-hidden="true"></div>
Connecting...
</>
) : (
@ -4824,7 +4824,7 @@ function SettingsSidebar({
>
{isConnectingSynologyCal ? (
<>
<div className="weekly-spinner weekly-spinner-white -ml-1 mr-2"></div>
<div className="weekly-spinner weekly-spinner-white -ml-1 mr-2" aria-hidden="true"></div>
Connecting...
</>
) : (

View File

@ -112,12 +112,14 @@ const TaskForm: React.FC<TaskFormProps> = ({ onSubmit, isLoading = false }) => {
name="title"
value={formData.title}
onChange={handleChange}
aria-invalid={!!errors.title}
aria-describedby={errors.title ? "title-error" : undefined}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${errors.title ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="What needs to be done?"
required
/>
{errors.title && <p className="mt-1 text-sm text-red-600">{errors.title}</p>}
{errors.title && <p id="title-error" role="alert" className="mt-1 text-sm text-red-600">{errors.title}</p>}
</div>
<div className="form-group mb-4">
@ -146,10 +148,12 @@ const TaskForm: React.FC<TaskFormProps> = ({ onSubmit, isLoading = false }) => {
name="startTime"
value={formData.startTime}
onChange={handleChange}
aria-invalid={!!errors.startTime}
aria-describedby={errors.startTime ? "startTime-error" : undefined}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${errors.startTime ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.startTime && <p className="mt-1 text-sm text-red-600">{errors.startTime}</p>}
{errors.startTime && <p id="startTime-error" role="alert" className="mt-1 text-sm text-red-600">{errors.startTime}</p>}
</div>
<div className="form-group">
@ -162,10 +166,12 @@ const TaskForm: React.FC<TaskFormProps> = ({ onSubmit, isLoading = false }) => {
name="endTime"
value={formData.endTime}
onChange={handleChange}
aria-invalid={!!errors.endTime}
aria-describedby={errors.endTime ? "endTime-error" : undefined}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${errors.endTime ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.endTime && <p className="mt-1 text-sm text-red-600">{errors.endTime}</p>}
{errors.endTime && <p id="endTime-error" role="alert" className="mt-1 text-sm text-red-600">{errors.endTime}</p>}
</div>
</div>
@ -200,7 +206,7 @@ const TaskForm: React.FC<TaskFormProps> = ({ onSubmit, isLoading = false }) => {
>
{isLoading ? (
<span className="flex items-center justify-center">
<div className="weekly-spinner weekly-spinner-white -ml-1 mr-2"></div>
<div className="weekly-spinner weekly-spinner-white -ml-1 mr-2" aria-hidden="true"></div>
Adding Task...
</span>
) : (

View File

@ -146,7 +146,7 @@ export default function TaskItem({ task, onToggleComplete, onDelete, onEdit, onT
aria-label="Delete task"
>
{isDeleting ? (
<div className="weekly-spinner"></div>
<div className="weekly-spinner" role="status" aria-label="Deleting task..."></div>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />

View File

@ -948,6 +948,13 @@ export default function WeeklyView() {
const [syncStatus, setSyncStatus] = useState<"idle" | "syncing" | "synced">(
"idle",
);
const [announceMsg, setAnnounceMsg] = useState('');
const announceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const announce = (msg: string) => {
if (announceTimeout.current) clearTimeout(announceTimeout.current);
setAnnounceMsg(msg);
announceTimeout.current = setTimeout(() => setAnnounceMsg(''), 3000);
};
const [cellDuration, setCellDuration] = useState<CellDuration>(30);
const [draggedTask, setDraggedTask] = useState<Task | null>(null);
const [showTimeGrid, setShowTimeGrid] = useState(true);
@ -3824,6 +3831,7 @@ export default function WeeklyView() {
const addTask = async (date: Date, title: string, startTime?: string) => {
if (!title.trim()) return;
saveSnapshot();
announce(`Task "${title.trim()}" added`);
const scheduledDate = formatDateToISO(date); // Use local date formatting
@ -3967,6 +3975,7 @@ export default function WeeklyView() {
if (!task) return;
const updatedCompleted = !task.completed;
announce(updatedCompleted ? `"${task.title}" marked complete` : `"${task.title}" marked incomplete`);
const isSomeday = !!task.somedayListId;
if (isSomeday) {
@ -4478,6 +4487,7 @@ export default function WeeklyView() {
const deleteTask = async (taskId: string) => {
saveSnapshot();
const taskToDelete = findTaskAnywhere(taskId);
if (taskToDelete) announce(`"${taskToDelete.title}" deleted`);
const isSomeday = !!taskToDelete?.somedayListId;
const isVirtual = taskId.startsWith("virtual-");
@ -5047,10 +5057,12 @@ export default function WeeklyView() {
setTimeout(() => fetchCalendarEvents(), 8000);
}
setSyncStatus("synced");
announce("Sync complete");
setTimeout(() => setSyncStatus("idle"), 3000);
} catch (error) {
console.error("Error syncing:", error);
setSyncStatus("idle");
announce("Sync failed");
setSyncError("Sync failed");
setTimeout(() => setSyncError(null), 10000);
}
@ -5384,6 +5396,15 @@ export default function WeeklyView() {
className={`weekly-container ${darkMode ? "dark-mode" : ""} font-size-${(profile.fontSize ?? "M").toLowerCase()} ${profile.viewStyle}-view ${profile.showTimeGrid ? "time-grid-on" : "time-grid-off"}${(() => { const hd = isMobile ? (isPortrait ? (profile.mobilePortraitHeaderDisplay || "current_day") : (profile.mobileLandscapeHeaderDisplay || profile.headerDisplay || "kw")) : (profile.headerDisplay || "kw"); return (viewDays === 1 && hd === "current_day") ? " header-current-day-single" : ""; })()}`}
style={containerStyle}
>
{/* Visually hidden live region for screen reader announcements */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
style={{ position: 'absolute', width: '1px', height: '1px', padding: 0, margin: '-1px', overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}
>
{announceMsg}
</div>
{/* Mobile sticky day indicator */}
{isMobile && mobileStickyDay && mobileStickyDayVisible && (
<div
@ -5718,7 +5739,7 @@ export default function WeeklyView() {
{syncError
? <AlertCircle size={14} className="text-red-500" title={syncError} />
: (isLoading || isSyncing || syncStatus === "syncing")
? <div className="weekly-spinner" title="Syncing..." style={{ width: 14, height: 14 }}></div>
? <div className="weekly-spinner" role="status" aria-label="Syncing..." style={{ width: 14, height: 14 }}></div>
: null}
</div>
@ -6970,6 +6991,9 @@ export default function WeeklyView() {
onProjectAssign={assignProject}
kanbanStages={kanbanStages}
weatherEnabled={effectiveWeatherEnabled}
onMoveTask={(taskId, targetDate, targetTime) => {
moveTaskToSlot(taskId, targetDate.getDay(), targetTime, targetDate);
}}
/>
))}
{visibleSlots.map((slot) => {

View File

@ -128,7 +128,7 @@ const EmailAuthForm: React.FC<EmailAuthFormProps> = ({ type, onSubmit, isLoading
>
{isLoading ? (
<span className="flex items-center">
<div className="weekly-spinner weekly-spinner-white -ml-1 mr-2"></div>
<div className="weekly-spinner weekly-spinner-white -ml-1 mr-2" aria-hidden="true"></div>
Processing...
</span>
) : type === 'signup' ? (