feat(web): TODO-003 accessibility focus trap + ARIA roles in modals

This commit is contained in:
saravanakumardb1 2026-04-18 18:00:00 -07:00
parent 0242ca85ff
commit 698fbc19cc
2 changed files with 71 additions and 2 deletions

View File

@ -1,8 +1,10 @@
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { useTimerStore } from '@/lib/store';
import { getUrgencyConfig } from '@/lib/urgency';
import { formatTime } from '@/lib/format';
import { alertLabel } from '@bytelyst/accessibility';
import { Bell, BellOff } from 'lucide-react';
export function AlarmOverlay() {
@ -11,15 +13,48 @@ export function AlarmOverlay() {
const firingTimers = timers.filter((t) => t.state === 'firing');
const overlayRef = useRef<HTMLDivElement>(null);
const trapFocus = useCallback((e: KeyboardEvent) => {
if (e.key !== 'Tab' || !overlayRef.current) return;
const focusable = overlayRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}, []);
useEffect(() => {
if (firingTimers.length === 0) return;
document.addEventListener('keydown', trapFocus);
// Auto-focus dismiss button
const el = overlayRef.current?.querySelector<HTMLElement>('button');
el?.focus();
return () => document.removeEventListener('keydown', trapFocus);
}, [firingTimers.length, trapFocus]);
if (firingTimers.length === 0) return null;
// Show the most urgent firing timer
const timer = firingTimers[0];
const urgencyConfig = getUrgencyConfig(timer.urgency);
const isCritical = timer.urgency === 'critical';
const a11yAlert = alertLabel(isCritical ? 'danger' : 'info', `Timer fired: ${timer.label}`);
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
<div
ref={overlayRef}
className="fixed inset-0 z-[100] flex items-center justify-center"
{...a11yAlert}
>
{/* Backdrop */}
<div
className="absolute inset-0"
@ -72,6 +107,7 @@ export function AlarmOverlay() {
<div className="flex gap-3">
<button
onClick={() => snooze(timer.id, 5)}
aria-label="Snooze for 5 minutes"
className="flex-1 py-3 rounded-xl text-sm font-medium transition-colors cursor-pointer flex items-center justify-center gap-2"
style={{
backgroundColor: 'var(--cm-surface-card)',
@ -83,6 +119,7 @@ export function AlarmOverlay() {
</button>
<button
onClick={() => snooze(timer.id, 15)}
aria-label="Snooze for 15 minutes"
className="flex-1 py-3 rounded-xl text-sm font-medium transition-colors cursor-pointer flex items-center justify-center gap-2"
style={{
backgroundColor: 'var(--cm-surface-card)',
@ -96,6 +133,7 @@ export function AlarmOverlay() {
<button
onClick={() => dismiss(timer.id)}
aria-label={`Dismiss ${timer.label} alarm`}
className="w-full py-3 rounded-xl text-sm font-medium transition-colors cursor-pointer"
style={{
backgroundColor: isCritical ? urgencyConfig.color : 'var(--cm-critical-20)',

View File

@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useTimerStore } from '@/lib/store';
import { URGENCY_ORDER, getUrgencyConfig } from '@/lib/urgency';
import type { UrgencyLevel } from '@/lib/urgency';
@ -23,6 +23,33 @@ interface CreateTimerModalProps {
export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
const { addAlarm, addCountdown, addPomodoro, addEvent } = useTimerStore();
const maintenanceMode = useMaintenanceMode();
const dialogRef = useRef<HTMLDivElement>(null);
const trapFocus = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') { onClose(); return; }
if (e.key !== 'Tab' || !dialogRef.current) return;
const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}, [onClose]);
useEffect(() => {
if (!isOpen) return;
document.addEventListener('keydown', trapFocus);
const el = dialogRef.current?.querySelector<HTMLElement>('input, button');
el?.focus();
return () => document.removeEventListener('keydown', trapFocus);
}, [isOpen, trapFocus]);
const [tab, setTab] = useState<TabType>('countdown');
const [nlInput, setNlInput] = useState('');
@ -215,6 +242,10 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
{/* Modal */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Create new timer"
className="relative w-full max-w-md mx-4 rounded-2xl border shadow-2xl overflow-hidden"
style={{
backgroundColor: 'var(--cm-bg-elevated)',