Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/components/accessibility/RouteChangeAnnouncer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use client';

import { usePathname } from 'next/navigation';
import { useFocusOnRouteChange } from '@/hooks/useAccessibility';

/**
* Manages focus on route changes by moving focus to the main content landmark.
* Render once inside RootProviders (client boundary).
*/
export function RouteChangeAnnouncer() {
const pathname = usePathname();
useFocusOnRouteChange(pathname);
return null;
}
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* Import from '@/components' rather than '@/app/components' for shared pieces.
*/

export * from './ui/Modal';
export * from './ui/Toast';
export * from './shared/EnvGuard';
export * from './errors/ErrorBoundarySystem';
90 changes: 90 additions & 0 deletions src/components/ui/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
'use client';

import { useEffect, useId } from 'react';
import { X } from 'lucide-react';
import { useFocusTrap, useScreenReaderAnnouncement } from '@/hooks/useAccessibility';

export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
/** Additional class names for the inner panel */
className?: string;
}

/**
* Accessible modal dialog with focus trap, Escape-to-close, and screen reader announcements.
* Uses the existing `useFocusTrap` hook from `useAccessibility`.
*/
export function Modal({ isOpen, onClose, title, children, className = '' }: ModalProps) {
const titleId = useId();
const containerRef = useFocusTrap(isOpen);
const announce = useScreenReaderAnnouncement();

// Announce open/close and lock body scroll
useEffect(() => {
if (isOpen) {
announce(`${title} dialog opened`, 'polite');
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen, title, announce]);

// Escape key closes the modal
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);

if (!isOpen) return null;

return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40 bg-black/50"
onClick={onClose}
aria-hidden="true"
/>

{/* Dialog */}
<div
ref={containerRef as React.RefObject<HTMLDivElement>}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<div
className={`relative w-full max-w-md max-h-[90vh] overflow-y-auto rounded-lg bg-white shadow-xl dark:bg-gray-900 ${className}`}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<h2 id={titleId} className="text-lg font-semibold text-gray-900 dark:text-gray-50">
{title}
</h2>
<button
onClick={onClose}
aria-label="Close dialog"
className="rounded p-1 text-gray-500 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:text-gray-400 dark:hover:text-gray-200"
>
<X size={20} aria-hidden="true" />
</button>
</div>

{/* Content */}
<div className="px-6 py-4">{children}</div>
</div>
</div>
</>
);
}
20 changes: 20 additions & 0 deletions src/hooks/useAccessibility.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,3 +245,23 @@ export function useReducedMotion() {

return prefersReducedMotion;
}

/**
* Hook that moves focus to the main content landmark on every route change.
* Call once inside a client component that has access to the pathname
* (e.g. a RouteChangeAnnouncer rendered inside the layout).
*/
export function useFocusOnRouteChange(pathname: string) {
useEffect(() => {
const main = document.querySelector<HTMLElement>('main, [role="main"]');
if (!main) return;
// Make main focusable if it isn't already, then focus it
const hadTabIndex = main.hasAttribute('tabindex');
if (!hadTabIndex) main.setAttribute('tabindex', '-1');
main.focus({ preventScroll: true });
if (!hadTabIndex) {
// Remove the temporary tabindex after focus so it doesn't appear in tab order
main.addEventListener('blur', () => main.removeAttribute('tabindex'), { once: true });
}
}, [pathname]);
}
2 changes: 2 additions & 0 deletions src/providers/RootProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { I18nProvider } from '@/hooks/useInternationalization';
import { InternationalizationEngine } from '@/components/i18n/InternationalizationEngine';
import { CulturalAdaptationManager } from '@/components/i18n/CulturalAdaptationManager';
import { AccessibilityProvider } from '@/components/accessibility/AccessibilityProvider';
import { RouteChangeAnnouncer } from '@/components/accessibility/RouteChangeAnnouncer';
import { ErrorBoundary } from '@/components/errors/ErrorBoundarySystem';
import { EnvGuard } from '@/components/shared/EnvGuard';
import { ToastProvider } from '@/context/ToastContext';
Expand Down Expand Up @@ -68,6 +69,7 @@ export function RootProviders({ children, defaultTheme }: RootProvidersProps) {
</Suspense>
<EnvGuard>
<AccessibilityProvider pageLabel="TeachLink - main application">
<RouteChangeAnnouncer />
<Suspense fallback={null}>
<PerformanceMonitoringProvider>
<OfflineModeProvider>
Expand Down
Loading