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';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
import { useTimerStore } from '@/lib/store';
|
import { useTimerStore } from '@/lib/store';
|
||||||
import { getUrgencyConfig } from '@/lib/urgency';
|
import { getUrgencyConfig } from '@/lib/urgency';
|
||||||
import { formatTime } from '@/lib/format';
|
import { formatTime } from '@/lib/format';
|
||||||
|
import { alertLabel } from '@bytelyst/accessibility';
|
||||||
import { Bell, BellOff } from 'lucide-react';
|
import { Bell, BellOff } from 'lucide-react';
|
||||||
|
|
||||||
export function AlarmOverlay() {
|
export function AlarmOverlay() {
|
||||||
@ -11,15 +13,48 @@ export function AlarmOverlay() {
|
|||||||
|
|
||||||
const firingTimers = timers.filter((t) => t.state === 'firing');
|
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;
|
if (firingTimers.length === 0) return null;
|
||||||
|
|
||||||
// Show the most urgent firing timer
|
// Show the most urgent firing timer
|
||||||
const timer = firingTimers[0];
|
const timer = firingTimers[0];
|
||||||
const urgencyConfig = getUrgencyConfig(timer.urgency);
|
const urgencyConfig = getUrgencyConfig(timer.urgency);
|
||||||
const isCritical = timer.urgency === 'critical';
|
const isCritical = timer.urgency === 'critical';
|
||||||
|
const a11yAlert = alertLabel(isCritical ? 'danger' : 'info', `Timer fired: ${timer.label}`);
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
@ -72,6 +107,7 @@ export function AlarmOverlay() {
|
|||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => snooze(timer.id, 5)}
|
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"
|
className="flex-1 py-3 rounded-xl text-sm font-medium transition-colors cursor-pointer flex items-center justify-center gap-2"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--cm-surface-card)',
|
backgroundColor: 'var(--cm-surface-card)',
|
||||||
@ -83,6 +119,7 @@ export function AlarmOverlay() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => snooze(timer.id, 15)}
|
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"
|
className="flex-1 py-3 rounded-xl text-sm font-medium transition-colors cursor-pointer flex items-center justify-center gap-2"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--cm-surface-card)',
|
backgroundColor: 'var(--cm-surface-card)',
|
||||||
@ -96,6 +133,7 @@ export function AlarmOverlay() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => dismiss(timer.id)}
|
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"
|
className="w-full py-3 rounded-xl text-sm font-medium transition-colors cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isCritical ? urgencyConfig.color : 'var(--cm-critical-20)',
|
backgroundColor: isCritical ? urgencyConfig.color : 'var(--cm-critical-20)',
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { useTimerStore } from '@/lib/store';
|
import { useTimerStore } from '@/lib/store';
|
||||||
import { URGENCY_ORDER, getUrgencyConfig } from '@/lib/urgency';
|
import { URGENCY_ORDER, getUrgencyConfig } from '@/lib/urgency';
|
||||||
import type { UrgencyLevel } from '@/lib/urgency';
|
import type { UrgencyLevel } from '@/lib/urgency';
|
||||||
@ -23,6 +23,33 @@ interface CreateTimerModalProps {
|
|||||||
export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
||||||
const { addAlarm, addCountdown, addPomodoro, addEvent } = useTimerStore();
|
const { addAlarm, addCountdown, addPomodoro, addEvent } = useTimerStore();
|
||||||
const maintenanceMode = useMaintenanceMode();
|
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 [tab, setTab] = useState<TabType>('countdown');
|
||||||
const [nlInput, setNlInput] = useState('');
|
const [nlInput, setNlInput] = useState('');
|
||||||
@ -215,6 +242,10 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
|||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
<div
|
<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"
|
className="relative w-full max-w-md mx-4 rounded-2xl border shadow-2xl overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--cm-bg-elevated)',
|
backgroundColor: 'var(--cm-bg-elevated)',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user