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:
parent
04b72a326a
commit
5c01869444
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"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",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -18,7 +18,19 @@ export const viewport: Viewport = {
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'My Weekly To Do List',
|
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: {
|
appleWebApp: {
|
||||||
capable: true,
|
capable: true,
|
||||||
statusBarStyle: 'default',
|
statusBarStyle: 'default',
|
||||||
|
|||||||
@ -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="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="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="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">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@ -44,10 +44,10 @@ const AuthForm: React.FC<AuthFormProps> = ({ type, onSubmit, isLoading, error })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{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">
|
||||||
<div className="flex-shrink-0">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@ -66,8 +66,8 @@ const AuthForm: React.FC<AuthFormProps> = ({ type, onSubmit, isLoading, error })
|
|||||||
Email address
|
Email address
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
<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">
|
<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="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" />
|
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
|
||||||
</svg>
|
</svg>
|
||||||
@ -80,6 +80,8 @@ const AuthForm: React.FC<AuthFormProps> = ({ type, onSubmit, isLoading, error })
|
|||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
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"
|
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"
|
placeholder="Email address"
|
||||||
/>
|
/>
|
||||||
@ -91,8 +93,8 @@ const AuthForm: React.FC<AuthFormProps> = ({ type, onSubmit, isLoading, error })
|
|||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
<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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@ -111,6 +113,8 @@ const AuthForm: React.FC<AuthFormProps> = ({ type, onSubmit, isLoading, error })
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
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"
|
className="text-gray-500 hover:text-gray-700 focus:outline-none transition-colors duration-200"
|
||||||
>
|
>
|
||||||
{showPassword ? (
|
{showPassword ? (
|
||||||
@ -141,7 +145,7 @@ const AuthForm: React.FC<AuthFormProps> = ({ type, onSubmit, isLoading, error })
|
|||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<span className="flex items-center">
|
<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...
|
Processing...
|
||||||
</span>
|
</span>
|
||||||
) : type === 'signup' ? (
|
) : type === 'signup' ? (
|
||||||
|
|||||||
@ -165,6 +165,30 @@ export default function CalendarEventModal({
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isCalendarSelectorOpen, setIsCalendarSelectorOpen] = useState(false);
|
const [isCalendarSelectorOpen, setIsCalendarSelectorOpen] = useState(false);
|
||||||
const calendarSelectorRef = useRef<HTMLDivElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
@ -363,8 +387,16 @@ export default function CalendarEventModal({
|
|||||||
const calColor = selectedCal?.backgroundColor || selectedCal?.color || '#3b82f6';
|
const calColor = selectedCal?.backgroundColor || selectedCal?.color || '#3b82f6';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="weekly-modal-overlay" onClick={onClose}>
|
<div className="weekly-modal-overlay" onClick={onClose} aria-hidden="true">
|
||||||
<div className="weekly-modal-content" onClick={e => e.stopPropagation()} style={{
|
<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',
|
maxWidth: '380px',
|
||||||
width: 'calc(100vw - 12px)',
|
width: 'calc(100vw - 12px)',
|
||||||
padding: '0',
|
padding: '0',
|
||||||
@ -391,6 +423,7 @@ export default function CalendarEventModal({
|
|||||||
value={title}
|
value={title}
|
||||||
onChange={e => setTitle(e.target.value)}
|
onChange={e => setTitle(e.target.value)}
|
||||||
placeholder={language === 'de' ? 'Titel hinzufügen' : 'Add title'}
|
placeholder={language === 'de' ? 'Titel hinzufügen' : 'Add title'}
|
||||||
|
aria-label={language === 'de' ? 'Ereignistitel' : 'Event title'}
|
||||||
autoFocus
|
autoFocus
|
||||||
style={{
|
style={{
|
||||||
width: '100%', padding: '3px 0', fontSize: '1rem', fontWeight: 600,
|
width: '100%', padding: '3px 0', fontSize: '1rem', fontWeight: 600,
|
||||||
|
|||||||
@ -248,7 +248,7 @@ const CalendarSettings: React.FC<CalendarSettingsProps> = ({
|
|||||||
>
|
>
|
||||||
{isSyncing[connection.id] ? (
|
{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...
|
Syncing...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -374,7 +374,7 @@ const CalendarSettings: React.FC<CalendarSettingsProps> = ({
|
|||||||
>
|
>
|
||||||
{isConnectingApple ? (
|
{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...
|
Connecting...
|
||||||
</>
|
</>
|
||||||
) : 'Connect'}
|
) : 'Connect'}
|
||||||
@ -424,7 +424,7 @@ const CalendarSettings: React.FC<CalendarSettingsProps> = ({
|
|||||||
>
|
>
|
||||||
{isConnectingApple ? (
|
{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...
|
Verifying...
|
||||||
</>
|
</>
|
||||||
) : 'Verify'}
|
) : 'Verify'}
|
||||||
|
|||||||
@ -43,6 +43,7 @@ interface GridTaskBlockProps {
|
|||||||
onProjectAssign?: (taskId: string, projectId: string | null) => void;
|
onProjectAssign?: (taskId: string, projectId: string | null) => void;
|
||||||
kanbanStages?: KanbanStage[];
|
kanbanStages?: KanbanStage[];
|
||||||
weatherEnabled?: boolean;
|
weatherEnabled?: boolean;
|
||||||
|
onMoveTask?: (taskId: string, targetDate: Date, targetTime: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GridTaskBlock({
|
export function GridTaskBlock({
|
||||||
@ -78,7 +79,8 @@ export function GridTaskBlock({
|
|||||||
projects,
|
projects,
|
||||||
onProjectAssign,
|
onProjectAssign,
|
||||||
kanbanStages = [],
|
kanbanStages = [],
|
||||||
weatherEnabled = false
|
weatherEnabled = false,
|
||||||
|
onMoveTask,
|
||||||
}: GridTaskBlockProps) {
|
}: GridTaskBlockProps) {
|
||||||
const [isNotesOpen, setIsNotesOpen] = useState(false);
|
const [isNotesOpen, setIsNotesOpen] = useState(false);
|
||||||
const [notesValue, setNotesValue] = useState(task.markdownContent || "");
|
const [notesValue, setNotesValue] = useState(task.markdownContent || "");
|
||||||
@ -89,9 +91,13 @@ export function GridTaskBlock({
|
|||||||
const [isSubTaskInputOpen, setIsSubTaskInputOpen] = useState(false);
|
const [isSubTaskInputOpen, setIsSubTaskInputOpen] = useState(false);
|
||||||
const [newSubTaskTitle, setNewSubTaskTitle] = useState("");
|
const [newSubTaskTitle, setNewSubTaskTitle] = useState("");
|
||||||
const [showProjectPicker, setShowProjectPicker] = useState(false);
|
const [showProjectPicker, setShowProjectPicker] = useState(false);
|
||||||
|
const [showMoveDialog, setShowMoveDialog] = useState(false);
|
||||||
|
const [moveDate, setMoveDate] = useState('');
|
||||||
|
const [moveTime, setMoveTime] = useState('');
|
||||||
const notesRef = useRef<HTMLTextAreaElement>(null);
|
const notesRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const subTaskInputRef = useRef<HTMLInputElement>(null);
|
const subTaskInputRef = useRef<HTMLInputElement>(null);
|
||||||
const projectPickerRef = useRef<HTMLDivElement>(null);
|
const projectPickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const moveDialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Touch: tap-to-reveal actions
|
// Touch: tap-to-reveal actions
|
||||||
const [touchActive, setTouchActive] = useState(false);
|
const [touchActive, setTouchActive] = useState(false);
|
||||||
@ -289,6 +295,7 @@ export function GridTaskBlock({
|
|||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
name="title"
|
name="title"
|
||||||
|
aria-label={`Edit task title: ${task.title}`}
|
||||||
autoFocus
|
autoFocus
|
||||||
defaultValue={task.title}
|
defaultValue={task.title}
|
||||||
onBlur={(e) => updateTask(task.id, e.target.value)}
|
onBlur={(e) => updateTask(task.id, e.target.value)}
|
||||||
@ -321,6 +328,7 @@ export function GridTaskBlock({
|
|||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={task.completed}
|
checked={task.completed}
|
||||||
|
aria-label={task.completed ? `Mark "${task.title}" incomplete` : `Mark "${task.title}" complete`}
|
||||||
onChange={(e) => { e.stopPropagation(); toggleTask(task.id); }}
|
onChange={(e) => { e.stopPropagation(); toggleTask(task.id); }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="task-checkbox flex-shrink-0"
|
className="task-checkbox flex-shrink-0"
|
||||||
@ -358,7 +366,8 @@ export function GridTaskBlock({
|
|||||||
setIsSubTasksOpen(!isSubTasksOpen);
|
setIsSubTasksOpen(!isSubTasksOpen);
|
||||||
}}
|
}}
|
||||||
className="subtask-indicator-badge"
|
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={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@ -377,7 +386,7 @@ export function GridTaskBlock({
|
|||||||
transition: "all 0.15s",
|
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={{
|
<span style={{
|
||||||
display: "inline-block",
|
display: "inline-block",
|
||||||
width: "36px",
|
width: "36px",
|
||||||
@ -411,7 +420,7 @@ export function GridTaskBlock({
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
title={task.url}
|
aria-label={`Open link: ${task.url}`}
|
||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@ -424,17 +433,18 @@ export function GridTaskBlock({
|
|||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Link size={10} />
|
<Link size={10} aria-hidden="true" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{/* Note indicator */}
|
{/* Note indicator */}
|
||||||
{task.markdownContent && task.markdownContent.trim().length > 0 && (
|
{task.markdownContent && task.markdownContent.trim().length > 0 && (
|
||||||
<div
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsNotesOpen(!isNotesOpen);
|
setIsNotesOpen(!isNotesOpen);
|
||||||
}}
|
}}
|
||||||
title={isNotesOpen ? "Collapse note" : "Expand note"}
|
aria-label={isNotesOpen ? "Collapse note" : "Expand note"}
|
||||||
|
aria-expanded={isNotesOpen}
|
||||||
style={{
|
style={{
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@ -443,14 +453,15 @@ export function GridTaskBlock({
|
|||||||
background: isNotesOpen ? "rgba(245, 158, 11, 0.12)" : "rgba(0,0,0,0.04)",
|
background: isNotesOpen ? "rgba(245, 158, 11, 0.12)" : "rgba(0,0,0,0.04)",
|
||||||
color: isNotesOpen ? "#f59e0b" : (darkMode ? "#888" : "#999"),
|
color: isNotesOpen ? "#f59e0b" : (darkMode ? "#888" : "#999"),
|
||||||
cursor: "pointer",
|
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" />
|
<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" />
|
<polyline points="14 2 14 8 20 8" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
)}
|
)}
|
||||||
{weatherEnabled && (() => {
|
{weatherEnabled && (() => {
|
||||||
const provider = task.externalProvider
|
const provider = task.externalProvider
|
||||||
@ -467,10 +478,10 @@ export function GridTaskBlock({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="flex-shrink-0"
|
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" }}
|
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>
|
</span>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@ -492,10 +503,10 @@ export function GridTaskBlock({
|
|||||||
if (!info) return null;
|
if (!info) return null;
|
||||||
return (
|
return (
|
||||||
<span
|
<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 }}
|
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>
|
</span>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@ -507,30 +518,40 @@ export function GridTaskBlock({
|
|||||||
<button
|
<button
|
||||||
className={`task-action-btn ${task.completed ? "active text-green-600 dark:text-green-500" : ""}`}
|
className={`task-action-btn ${task.completed ? "active text-green-600 dark:text-green-500" : ""}`}
|
||||||
onClick={(e) => { e.stopPropagation(); toggleTask(task.id); }}
|
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" />
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="task-action-btn"
|
className="task-action-btn"
|
||||||
onClick={(e) => { e.stopPropagation(); setEditingTaskId(task.id); }}
|
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="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" />
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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">
|
<button
|
||||||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
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="12" y1="5" x2="12" y2="19" />
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button className={`task-action-btn ${isNotesOpen ? "active" : ""}`} onClick={(e) => { e.stopPropagation(); setIsNotesOpen(!isNotesOpen); }} title="Notes">
|
<button
|
||||||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
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="12" x2="21" y2="12" />
|
||||||
<line x1="3" y1="6" x2="21" y2="6" />
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
<line x1="3" y1="18" x2="21" y2="18" />
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
@ -540,9 +561,10 @@ export function GridTaskBlock({
|
|||||||
<button
|
<button
|
||||||
className={`task-action-btn ${task.isRolling ? "active" : ""}`}
|
className={`task-action-btn ${task.isRolling ? "active" : ""}`}
|
||||||
onClick={(e) => { e.stopPropagation(); toggleTaskRolling(task.id); }}
|
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" />
|
<polyline points="23 4 23 10 17 10" />
|
||||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||||
</svg>
|
</svg>
|
||||||
@ -552,9 +574,9 @@ export function GridTaskBlock({
|
|||||||
<button
|
<button
|
||||||
className={`task-action-btn ${task.isRecurring ? "active" : ""}`}
|
className={`task-action-btn ${task.isRecurring ? "active" : ""}`}
|
||||||
onClick={(e) => { e.stopPropagation(); setSelectedTaskForRecurrence(task); }}
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -567,10 +589,13 @@ export function GridTaskBlock({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setShowProjectPicker(!showProjectPicker);
|
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
|
<Circle
|
||||||
size={12}
|
size={12}
|
||||||
|
aria-hidden="true"
|
||||||
fill={task.project?.color || "none"}
|
fill={task.project?.color || "none"}
|
||||||
stroke={task.project?.color || "currentColor"}
|
stroke={task.project?.color || "currentColor"}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
@ -611,17 +636,49 @@ export function GridTaskBlock({
|
|||||||
<button
|
<button
|
||||||
className={`task-action-btn ${task.url || isUrlOpen ? "active" : ""}`}
|
className={`task-action-btn ${task.url || isUrlOpen ? "active" : ""}`}
|
||||||
onClick={(e) => { e.stopPropagation(); setIsUrlOpen(!isUrlOpen); }}
|
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" } : {}}
|
style={task.url ? { color: "#2563eb" } : {}}
|
||||||
>
|
>
|
||||||
<Link size={12} />
|
<Link size={12} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<button className="task-action-btn delete" onClick={(e) => { e.stopPropagation(); deleteTask(task.id); }} title="Delete">
|
<button
|
||||||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2.5" fill="none" strokeLinecap="round" strokeLinejoin="round">
|
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>
|
<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>
|
<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>
|
</svg>
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div style={{ paddingLeft: "4px", paddingRight: "4px", paddingBottom: "10px", marginTop: "4px" }}>
|
<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" }}>
|
<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
|
<input
|
||||||
ref={urlInputRef}
|
ref={urlInputRef}
|
||||||
type="url"
|
type="url"
|
||||||
|
aria-label="Task link URL"
|
||||||
value={urlValue}
|
value={urlValue}
|
||||||
onChange={(e) => setUrlValue(e.target.value)}
|
onChange={(e) => setUrlValue(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@ -670,10 +728,10 @@ export function GridTaskBlock({
|
|||||||
{urlValue && (
|
{urlValue && (
|
||||||
<button
|
<button
|
||||||
onMouseDown={(e) => { e.preventDefault(); setUrlValue(""); updateTaskUrl(task.id, ""); setIsUrlOpen(false); }}
|
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" }}
|
style={{ background: "none", border: "none", cursor: "pointer", padding: "2px", color: "#999" }}
|
||||||
>
|
>
|
||||||
<X size={12} />
|
<X size={12} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{urlValue && (
|
{urlValue && (
|
||||||
@ -682,10 +740,10 @@ export function GridTaskBlock({
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
title="Open link"
|
aria-label="Open link in new tab"
|
||||||
style={{ color: "#2563eb", flexShrink: 0, display: "inline-flex" }}
|
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" />
|
<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" />
|
<polyline points="15 3 21 3 21 9" />
|
||||||
<line x1="10" y1="14" x2="21" y2="3" />
|
<line x1="10" y1="14" x2="21" y2="3" />
|
||||||
@ -714,7 +772,7 @@ export function GridTaskBlock({
|
|||||||
<button
|
<button
|
||||||
onMouseDown={(e) => { e.preventDefault(); setIsNotesOpen(false); }}
|
onMouseDown={(e) => { e.preventDefault(); setIsNotesOpen(false); }}
|
||||||
style={{ background: "none", border: "none", cursor: "pointer", fontSize: "1rem", lineHeight: 1, color: "#999", padding: "2px 4px" }}
|
style={{ background: "none", border: "none", cursor: "pointer", fontSize: "1rem", lineHeight: 1, color: "#999", padding: "2px 4px" }}
|
||||||
title="Close"
|
aria-label="Close notes"
|
||||||
>×</button>
|
>×</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: "0 6px 8px" }}>
|
<div style={{ padding: "0 6px 8px" }}>
|
||||||
@ -734,11 +792,15 @@ export function GridTaskBlock({
|
|||||||
<ul className="subtask-list mt-1" onClick={(e) => e.stopPropagation()}>
|
<ul className="subtask-list mt-1" onClick={(e) => e.stopPropagation()}>
|
||||||
{task.subTasks.map((subTask: Task) => (
|
{task.subTasks.map((subTask: Task) => (
|
||||||
<li key={subTask.id} className={`subtask-item ${subTask.completed ? "completed" : ""}`}>
|
<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 ? (
|
{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>
|
</button>
|
||||||
{onSetEditingTaskId && editingTaskId === subTask.id ? (
|
{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>
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -756,12 +818,87 @@ export function GridTaskBlock({
|
|||||||
{isSubTaskInputOpen && (
|
{isSubTaskInputOpen && (
|
||||||
<div className="subtask-add-row mt-1" onClick={(e) => e.stopPropagation()}>
|
<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 }}>
|
<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>
|
<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" 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 />
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 className="resize-handle" onMouseDown={onResizeStart} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { X, Check, Loader } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
interface ImportListModalProps {
|
interface ImportListModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -19,14 +19,45 @@ export const ImportListModal: React.FC<ImportListModalProps> = ({
|
|||||||
isLoading
|
isLoading
|
||||||
}) => {
|
}) => {
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
|
setTimeout(() => firstFocusRef.current?.focus(), 50);
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
const toggleSelection = (id: string) => {
|
const toggleSelection = (id: string) => {
|
||||||
@ -46,43 +77,62 @@ export const ImportListModal: React.FC<ImportListModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div
|
||||||
position: 'fixed',
|
style={{
|
||||||
top: 0,
|
position: 'fixed',
|
||||||
left: 0,
|
top: 0, left: 0, right: 0, bottom: 0,
|
||||||
right: 0,
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
zIndex: 1000
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
background: 'white',
|
|
||||||
padding: '2rem',
|
|
||||||
borderRadius: '8px',
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: '500px',
|
|
||||||
maxHeight: '80vh',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
alignItems: 'center',
|
||||||
gap: '1rem',
|
justifyContent: 'center',
|
||||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)'
|
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' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 600 }}>
|
<h2 id="import-modal-title" style={{ fontSize: '1.25rem', fontWeight: 600 }}>
|
||||||
Sync with {provider === 'google' ? 'Google Tasks' : provider === 'outlook' ? 'Microsoft To-Do' : 'Apple Reminders'}
|
Sync with {providerLabel}
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={onClose} className="p-1 hover:bg-gray-100 rounded-full">
|
<button
|
||||||
<X size={20} />
|
ref={firstFocusRef}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close dialog"
|
||||||
|
className="p-1 hover:bg-gray-100 rounded-full"
|
||||||
|
>
|
||||||
|
<X size={20} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: '200px' }}>
|
<div style={{ flex: 1, overflowY: 'auto', minHeight: '200px' }}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
<div
|
||||||
<div className="weekly-spinner"></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>
|
<span style={{ marginLeft: '8px' }}>Loading lists...</span>
|
||||||
</div>
|
</div>
|
||||||
) : lists.length === 0 ? (
|
) : lists.length === 0 ? (
|
||||||
@ -94,33 +144,37 @@ export const ImportListModal: React.FC<ImportListModalProps> = ({
|
|||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '8px' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '8px' }}>
|
||||||
<button
|
<button
|
||||||
onClick={handleSelectAll}
|
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' }}
|
style={{ fontSize: '0.875rem', color: '#3b82f6', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
{selectedIds.length === lists.length ? 'Deselect All' : 'Select All'}
|
{selectedIds.length === lists.length ? 'Deselect All' : 'Select All'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{lists.map(list => (
|
<ul role="list" style={{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
<label
|
{lists.map(list => (
|
||||||
key={list.id}
|
<li key={list.id} role="listitem">
|
||||||
style={{
|
<label
|
||||||
display: 'flex',
|
style={{
|
||||||
alignItems: 'center',
|
display: 'flex',
|
||||||
gap: '10px',
|
alignItems: 'center',
|
||||||
padding: '8px',
|
gap: '10px',
|
||||||
borderRadius: '4px',
|
padding: '8px',
|
||||||
cursor: 'pointer',
|
borderRadius: '4px',
|
||||||
backgroundColor: selectedIds.includes(list.id) ? '#eff6ff' : 'transparent'
|
cursor: 'pointer',
|
||||||
}}
|
backgroundColor: selectedIds.includes(list.id) ? '#eff6ff' : 'transparent'
|
||||||
>
|
}}
|
||||||
<input
|
>
|
||||||
type="checkbox"
|
<input
|
||||||
checked={selectedIds.includes(list.id)}
|
type="checkbox"
|
||||||
onChange={() => toggleSelection(list.id)}
|
checked={selectedIds.includes(list.id)}
|
||||||
style={{ width: '16px', height: '16px' }}
|
onChange={() => toggleSelection(list.id)}
|
||||||
/>
|
style={{ width: '16px', height: '16px' }}
|
||||||
<span>{list.title}</span>
|
/>
|
||||||
</label>
|
<span>{list.title}</span>
|
||||||
))}
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -141,6 +195,8 @@ export const ImportListModal: React.FC<ImportListModalProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => onImport(lists.filter(l => selectedIds.includes(l.id)))}
|
onClick={() => onImport(lists.filter(l => selectedIds.includes(l.id)))}
|
||||||
disabled={selectedIds.length === 0 || isLoading}
|
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={{
|
style={{
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
|
|||||||
@ -1079,14 +1079,18 @@ export default function OnboardingWizard({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="onboarding-overlay">
|
<div className="onboarding-overlay" aria-hidden="true">
|
||||||
<div
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={`Setup wizard, step ${currentStep + 1} of ${STEPS.length}`}
|
||||||
className="onboarding-card"
|
className="onboarding-card"
|
||||||
style={{
|
style={{
|
||||||
background: bg, color: textColor,
|
background: bg, color: textColor,
|
||||||
maxWidth: isWideStep ? "1060px" : "560px",
|
maxWidth: isWideStep ? "1060px" : "560px",
|
||||||
transition: "max-width 0.3s ease",
|
transition: "max-width 0.3s ease",
|
||||||
}}
|
}}
|
||||||
|
aria-hidden="false"
|
||||||
>
|
>
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
{currentStep > 0 && (
|
{currentStep > 0 && (
|
||||||
@ -1096,9 +1100,9 @@ export default function OnboardingWizard({
|
|||||||
position: "absolute", top: "12px", right: "12px", background: "none", border: "none",
|
position: "absolute", top: "12px", right: "12px", background: "none", border: "none",
|
||||||
color: mutedColor, cursor: "pointer", padding: "4px", zIndex: 10,
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -1113,15 +1117,17 @@ export default function OnboardingWizard({
|
|||||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
padding: "16px 30px 24px", borderTop: `1px solid ${borderColor}`,
|
padding: "16px 30px 24px", borderTop: `1px solid ${borderColor}`,
|
||||||
}}>
|
}}>
|
||||||
<button onClick={goBack} style={secondaryBtn}>
|
<button onClick={goBack} style={secondaryBtn} aria-label={t.back}>
|
||||||
<ChevronLeft size={16} /> {t.back}
|
<ChevronLeft size={16} aria-hidden="true" /> {t.back}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Progress dots */}
|
{/* Progress dots */}
|
||||||
<div style={{ display: "flex", gap: "6px" }}>
|
<nav aria-label="Setup progress">
|
||||||
{STEPS.map((_, i) => (
|
<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
|
<div
|
||||||
key={i}
|
aria-label={`Step ${i + 1}: ${stepName}${i < currentStep ? " (completed)" : i === currentStep ? " (current)" : ""}`}
|
||||||
style={{
|
style={{
|
||||||
width: i === currentStep ? "20px" : "8px", height: "8px",
|
width: i === currentStep ? "20px" : "8px", height: "8px",
|
||||||
borderRadius: "4px", transition: "all 0.3s",
|
borderRadius: "4px", transition: "all 0.3s",
|
||||||
@ -1129,15 +1135,17 @@ export default function OnboardingWizard({
|
|||||||
opacity: i < currentStep ? 0.5 : 1,
|
opacity: i < currentStep ? 0.5 : 1,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
{isLastStep ? (
|
{isLastStep ? (
|
||||||
<button onClick={handleFinish} style={primaryBtn}>
|
<button onClick={handleFinish} style={primaryBtn}>
|
||||||
{t.finish}
|
{t.finish}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button onClick={goNext} style={primaryBtn}>
|
<button onClick={goNext} style={primaryBtn} aria-label={t.next}>
|
||||||
{t.next} <ChevronRight size={16} />
|
{t.next} <ChevronRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export default function SearchModal({ isOpen, onClose, tasks, events, somedayLis
|
|||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [results, setResults] = useState<{ type: 'task' | 'event' | 'someday', item: any }[]>([]);
|
const [results, setResults] = useState<{ type: 'task' | 'event' | 'someday', item: any }[]>([]);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && inputRef.current) {
|
if (isOpen && inputRef.current) {
|
||||||
@ -28,6 +29,30 @@ export default function SearchModal({ isOpen, onClose, tasks, events, somedayLis
|
|||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
@ -57,82 +82,116 @@ export default function SearchModal({ isOpen, onClose, tasks, events, somedayLis
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center pt-20" onClick={onClose}>
|
<div
|
||||||
<div className="rounded-lg shadow-2xl w-[600px] max-w-[90%] overflow-hidden" style={{ backgroundColor: 'var(--weekly-bg, #ffffff)' }} onClick={e => e.stopPropagation()}>
|
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">
|
<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>
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
id="search-modal-input"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search tasks and events..."
|
placeholder="Search tasks and events..."
|
||||||
|
aria-label="Search tasks and events"
|
||||||
className="flex-1 text-lg outline-none text-gray-700 placeholder-gray-400"
|
className="flex-1 text-lg outline-none text-gray-700 placeholder-gray-400"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={e => setQuery(e.target.value)}
|
onChange={e => setQuery(e.target.value)}
|
||||||
onKeyDown={e => e.key === 'Escape' && onClose()}
|
onKeyDown={e => e.key === 'Escape' && onClose()}
|
||||||
/>
|
/>
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
<button
|
||||||
<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>
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[60vh] overflow-y-auto">
|
<div
|
||||||
|
className="max-h-[60vh] overflow-y-auto"
|
||||||
|
role="region"
|
||||||
|
aria-label="Search results"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="false"
|
||||||
|
>
|
||||||
{results.length > 0 ? (
|
{results.length > 0 ? (
|
||||||
<div className="py-2">
|
<ul className="py-2" role="list">
|
||||||
{results.map((result, idx) => (
|
{results.map((result, idx) => (
|
||||||
<div
|
<li key={`${result.type}-${result.item.id}-${idx}`} role="listitem">
|
||||||
key={`${result.type}-${result.item.id}-${idx}`}
|
<button
|
||||||
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="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={() => {
|
onClick={() => {
|
||||||
if (result.type === 'someday') {
|
if (result.type === 'someday') {
|
||||||
onClose();
|
onClose();
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
const date = result.type === 'task'
|
|
||||||
? (result.item.scheduledDate ? new Date(result.item.scheduledDate) : new Date())
|
|
||||||
: (new Date(result.item.startTime));
|
|
||||||
|
|
||||||
onSelectTask(date);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<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'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
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>
|
||||||
</div>
|
<div className="flex-1">
|
||||||
{(result.type === 'task' || result.type === 'someday') && result.item.completed && (
|
<div className="font-medium text-gray-800">{result.item.title}</div>
|
||||||
<span className="text-xs bg-gray-100 text-gray-500 px-2 py-1 rounded">Completed</span>
|
<div className="text-xs text-gray-500">
|
||||||
)}
|
{result.type === 'someday'
|
||||||
</div>
|
? `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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</ul>
|
||||||
) : query.trim() ? (
|
) : query.trim() ? (
|
||||||
<div className="p-8 text-center text-gray-400">
|
<p className="p-8 text-center text-gray-400" role="status">
|
||||||
No Item found.
|
No item found.
|
||||||
</div>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-8 text-center text-gray-400">
|
<p className="p-8 text-center text-gray-400">
|
||||||
Start typing to search...
|
Start typing to search...
|
||||||
</div>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4730,7 +4730,7 @@ function SettingsSidebar({
|
|||||||
>
|
>
|
||||||
{isConnectingAppleCal ? (
|
{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...
|
Connecting...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@ -4824,7 +4824,7 @@ function SettingsSidebar({
|
|||||||
>
|
>
|
||||||
{isConnectingSynologyCal ? (
|
{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...
|
Connecting...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -112,12 +112,14 @@ const TaskForm: React.FC<TaskFormProps> = ({ onSubmit, isLoading = false }) => {
|
|||||||
name="title"
|
name="title"
|
||||||
value={formData.title}
|
value={formData.title}
|
||||||
onChange={handleChange}
|
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'
|
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?"
|
placeholder="What needs to be done?"
|
||||||
required
|
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>
|
||||||
|
|
||||||
<div className="form-group mb-4">
|
<div className="form-group mb-4">
|
||||||
@ -146,10 +148,12 @@ const TaskForm: React.FC<TaskFormProps> = ({ onSubmit, isLoading = false }) => {
|
|||||||
name="startTime"
|
name="startTime"
|
||||||
value={formData.startTime}
|
value={formData.startTime}
|
||||||
onChange={handleChange}
|
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'
|
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>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@ -162,10 +166,12 @@ const TaskForm: React.FC<TaskFormProps> = ({ onSubmit, isLoading = false }) => {
|
|||||||
name="endTime"
|
name="endTime"
|
||||||
value={formData.endTime}
|
value={formData.endTime}
|
||||||
onChange={handleChange}
|
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'
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -200,7 +206,7 @@ const TaskForm: React.FC<TaskFormProps> = ({ onSubmit, isLoading = false }) => {
|
|||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<span className="flex items-center justify-center">
|
<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...
|
Adding Task...
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -146,7 +146,7 @@ export default function TaskItem({ task, onToggleComplete, onDelete, onEdit, onT
|
|||||||
aria-label="Delete task"
|
aria-label="Delete task"
|
||||||
>
|
>
|
||||||
{isDeleting ? (
|
{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">
|
<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" />
|
<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" />
|
||||||
|
|||||||
@ -948,6 +948,13 @@ export default function WeeklyView() {
|
|||||||
const [syncStatus, setSyncStatus] = useState<"idle" | "syncing" | "synced">(
|
const [syncStatus, setSyncStatus] = useState<"idle" | "syncing" | "synced">(
|
||||||
"idle",
|
"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 [cellDuration, setCellDuration] = useState<CellDuration>(30);
|
||||||
const [draggedTask, setDraggedTask] = useState<Task | null>(null);
|
const [draggedTask, setDraggedTask] = useState<Task | null>(null);
|
||||||
const [showTimeGrid, setShowTimeGrid] = useState(true);
|
const [showTimeGrid, setShowTimeGrid] = useState(true);
|
||||||
@ -3824,6 +3831,7 @@ export default function WeeklyView() {
|
|||||||
const addTask = async (date: Date, title: string, startTime?: string) => {
|
const addTask = async (date: Date, title: string, startTime?: string) => {
|
||||||
if (!title.trim()) return;
|
if (!title.trim()) return;
|
||||||
saveSnapshot();
|
saveSnapshot();
|
||||||
|
announce(`Task "${title.trim()}" added`);
|
||||||
|
|
||||||
const scheduledDate = formatDateToISO(date); // Use local date formatting
|
const scheduledDate = formatDateToISO(date); // Use local date formatting
|
||||||
|
|
||||||
@ -3967,6 +3975,7 @@ export default function WeeklyView() {
|
|||||||
if (!task) return;
|
if (!task) return;
|
||||||
|
|
||||||
const updatedCompleted = !task.completed;
|
const updatedCompleted = !task.completed;
|
||||||
|
announce(updatedCompleted ? `"${task.title}" marked complete` : `"${task.title}" marked incomplete`);
|
||||||
const isSomeday = !!task.somedayListId;
|
const isSomeday = !!task.somedayListId;
|
||||||
|
|
||||||
if (isSomeday) {
|
if (isSomeday) {
|
||||||
@ -4478,6 +4487,7 @@ export default function WeeklyView() {
|
|||||||
const deleteTask = async (taskId: string) => {
|
const deleteTask = async (taskId: string) => {
|
||||||
saveSnapshot();
|
saveSnapshot();
|
||||||
const taskToDelete = findTaskAnywhere(taskId);
|
const taskToDelete = findTaskAnywhere(taskId);
|
||||||
|
if (taskToDelete) announce(`"${taskToDelete.title}" deleted`);
|
||||||
const isSomeday = !!taskToDelete?.somedayListId;
|
const isSomeday = !!taskToDelete?.somedayListId;
|
||||||
const isVirtual = taskId.startsWith("virtual-");
|
const isVirtual = taskId.startsWith("virtual-");
|
||||||
|
|
||||||
@ -5047,10 +5057,12 @@ export default function WeeklyView() {
|
|||||||
setTimeout(() => fetchCalendarEvents(), 8000);
|
setTimeout(() => fetchCalendarEvents(), 8000);
|
||||||
}
|
}
|
||||||
setSyncStatus("synced");
|
setSyncStatus("synced");
|
||||||
|
announce("Sync complete");
|
||||||
setTimeout(() => setSyncStatus("idle"), 3000);
|
setTimeout(() => setSyncStatus("idle"), 3000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error syncing:", error);
|
console.error("Error syncing:", error);
|
||||||
setSyncStatus("idle");
|
setSyncStatus("idle");
|
||||||
|
announce("Sync failed");
|
||||||
setSyncError("Sync failed");
|
setSyncError("Sync failed");
|
||||||
setTimeout(() => setSyncError(null), 10000);
|
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" : ""; })()}`}
|
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}
|
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 */}
|
{/* Mobile sticky day indicator */}
|
||||||
{isMobile && mobileStickyDay && mobileStickyDayVisible && (
|
{isMobile && mobileStickyDay && mobileStickyDayVisible && (
|
||||||
<div
|
<div
|
||||||
@ -5718,7 +5739,7 @@ export default function WeeklyView() {
|
|||||||
{syncError
|
{syncError
|
||||||
? <AlertCircle size={14} className="text-red-500" title={syncError} />
|
? <AlertCircle size={14} className="text-red-500" title={syncError} />
|
||||||
: (isLoading || isSyncing || syncStatus === "syncing")
|
: (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}
|
: null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -6970,6 +6991,9 @@ export default function WeeklyView() {
|
|||||||
onProjectAssign={assignProject}
|
onProjectAssign={assignProject}
|
||||||
kanbanStages={kanbanStages}
|
kanbanStages={kanbanStages}
|
||||||
weatherEnabled={effectiveWeatherEnabled}
|
weatherEnabled={effectiveWeatherEnabled}
|
||||||
|
onMoveTask={(taskId, targetDate, targetTime) => {
|
||||||
|
moveTaskToSlot(taskId, targetDate.getDay(), targetTime, targetDate);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{visibleSlots.map((slot) => {
|
{visibleSlots.map((slot) => {
|
||||||
|
|||||||
@ -128,7 +128,7 @@ const EmailAuthForm: React.FC<EmailAuthFormProps> = ({ type, onSubmit, isLoading
|
|||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<span className="flex items-center">
|
<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...
|
Processing...
|
||||||
</span>
|
</span>
|
||||||
) : type === 'signup' ? (
|
) : type === 'signup' ? (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user