feat(web): TODO-003 accessibility focus trap + ARIA roles in modals
This commit is contained in:
parent
0242ca85ff
commit
698fbc19cc
@ -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)',
|
||||
|
||||
@ -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)',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user