From a7bb960c62d45ed750a5c06eb3e9a273323a4adc Mon Sep 17 00:00:00 2001 From: DeborahOlaboye Date: Fri, 5 Dec 2025 04:34:34 +0100 Subject: [PATCH] feat: Add accessibility utilities and documentation - Create accessibility.ts with focus management and ARIA utilities - Add comprehensive ACCESSIBILITY.md documentation - Implement keyboard navigation and screen reader support - Add skip links and focus trapping for modals --- frontend/docs/ACCESSIBILITY.md | 147 ++++++++++++++++++ frontend/lib/accessibility.ts | 272 +++++++++++++++++++++++++++++++++ 2 files changed, 419 insertions(+) create mode 100644 frontend/docs/ACCESSIBILITY.md create mode 100644 frontend/lib/accessibility.ts diff --git a/frontend/docs/ACCESSIBILITY.md b/frontend/docs/ACCESSIBILITY.md new file mode 100644 index 0000000..486b5f8 --- /dev/null +++ b/frontend/docs/ACCESSIBILITY.md @@ -0,0 +1,147 @@ +# Accessibility Implementation + +This document outlines the accessibility features and best practices implemented in the application to ensure it's usable by everyone, including people with disabilities. + +## Key Accessibility Features + +### 1. Keyboard Navigation +- Full keyboard navigation support +- Logical tab order +- Visual focus indicators +- Skip links for bypassing repetitive content + +### 2. Screen Reader Support +- ARIA attributes for dynamic content +- Live regions for announcements +- Proper heading structure +- Semantic HTML elements + +### 3. Focus Management +- Programmatic focus management +- Focus trapping for modals and dialogs +- Focus restoration after interactions + +### 4. Color and Contrast +- Sufficient color contrast ratios +- Color-independent information +- Dark/light mode support + +## Using the Accessibility Utilities + +### Focus Management + +```typescript +import { focusFirstFocusable, focusLastFocusable, trapFocus } from '@/lib/accessibility'; + +// Focus the first focusable element in a container +focusFirstFocusable(containerElement); + +// Focus the last focusable element in a container +focusLastFocusable(containerElement); + +// Trap focus within a modal/dialog +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + // Close modal logic + } +}); +``` + +### ARIA Live Regions + +```typescript +import { announce } from '@/lib/accessibility'; + +// Announce a message to screen readers +announce('Form submitted successfully', 'polite'); +``` + +### Skip Links + +Skip links are automatically added to the page. Add an `id="main-content"` to your main content area: + +```html +
+ +
+``` + +## Testing Accessibility + +### Automated Testing + +1. **Lighthouse** - Run Lighthouse in Chrome DevTools to check for accessibility issues +2. **axe DevTools** - Browser extension for accessibility testing +3. **WAVE** - Web Accessibility Evaluation Tool + +### Manual Testing + +1. **Keyboard Navigation** + - Navigate using only the Tab key + - Ensure all interactive elements are reachable + - Check that focus order is logical + +2. **Screen Reader Testing** + - Test with VoiceOver (Safari/Mac) + - Test with NVDA (Firefox/Windows) + - Test with JAWS (Chrome/Windows) + +3. **Color Contrast** + - Verify text has sufficient contrast (4.5:1 for normal text) + - Check that color is not the only means of conveying information + +## Common Patterns + +### Accessible Buttons + +```tsx + +``` + +### Accessible Forms + +```tsx +<> + + +

Enter your username or email address

+ +``` + +### Accessible Dialogs + +```tsx +
+

Confirmation

+

Are you sure you want to delete this item?

+ +
+ + +
+
+``` + +## Resources + +- [Web Content Accessibility Guidelines (WCAG) 2.1](https://www.w3.org/TR/WCAG21/) +- [ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/) +- [WebAIM Checklist](https://webaim.org/standards/wcag/checklist) +- [a11y Project Checklist](https://www.a11yproject.com/checklist/) diff --git a/frontend/lib/accessibility.ts b/frontend/lib/accessibility.ts new file mode 100644 index 0000000..5c5839a --- /dev/null +++ b/frontend/lib/accessibility.ts @@ -0,0 +1,272 @@ +// Accessibility utilities for keyboard navigation, focus management, and ARIA + +export const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + +export function trapFocus(element: HTMLElement) { + const focusableContent = element.querySelectorAll(focusableElements); + const firstFocusableElement = focusableContent[0] as HTMLElement; + const lastFocusableElement = focusableContent[focusableContent.length - 1] as HTMLElement; + + element.addEventListener('keydown', function(e) { + if (e.key !== 'Tab') return; + + if (e.shiftKey) { + if (document.activeElement === firstFocusableElement) { + lastFocusableElement.focus(); + e.preventDefault(); + } + } else { + if (document.activeElement === lastFocusableElement) { + firstFocusableElement.focus(); + e.preventDefault(); + } + } + }); + + firstFocusableElement.focus(); +} + +export function isFocusable(element: Element): boolean { + if (!(element instanceof HTMLElement)) return false; + + // Element is not visible + if (element.offsetParent === null) return false; + + // Element is disabled + if ((element as HTMLButtonElement).disabled) return false; + + // Element is a link without href + if (element.tagName === 'A' && !(element as HTMLAnchorElement).href) return false; + + // Check tabindex + const tabIndex = element.getAttribute('tabindex'); + if (tabIndex === '-1') return false; + + return true; +} + +export function getFocusableElements(container: HTMLElement | Document = document): HTMLElement[] { + return Array.from(container.querySelectorAll(focusableElements)).filter( + (el): el is HTMLElement => isFocusable(el) + ); +} + +export function getFirstFocusable(container: HTMLElement | Document = document): HTMLElement | null { + const focusable = getFocusableElements(container); + return focusable.length > 0 ? focusable[0] : null; +} + +export function getLastFocusable(container: HTMLElement | Document = document): HTMLElement | null { + const focusable = getFocusableElements(container); + return focusable.length > 0 ? focusable[focusable.length - 1] : null; +} + +export function getNextFocusable(current: Element, container: HTMLElement | Document = document): HTMLElement | null { + const focusable = getFocusableElements(container); + const currentIndex = focusable.indexOf(current as HTMLElement); + + if (currentIndex === -1 || currentIndex === focusable.length - 1) { + return focusable[0] || null; + } + + return focusable[currentIndex + 1] || null; +} + +export function getPreviousFocusable(current: Element, container: HTMLElement | Document = document): HTMLElement | null { + const focusable = getFocusableElements(container); + const currentIndex = focusable.indexOf(current as HTMLElement); + + if (currentIndex <= 0) { + return focusable[focusable.length - 1] || null; + } + + return focusable[currentIndex - 1] || null; +} + +// ARIA Live Regions +export const AriaLiveRegion = { + polite: 'polite', + assertive: 'assertive', + off: 'off' +} as const; + +export function createLiveRegion(priority: keyof typeof AriaLiveRegion = 'polite'): HTMLDivElement { + const liveRegion = document.createElement('div'); + liveRegion.setAttribute('aria-live', priority); + liveRegion.setAttribute('aria-atomic', 'true'); + Object.assign(liveRegion.style, { + position: 'absolute', + width: '1px', + height: '1px', + padding: 0, + margin: '-1px', + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: 0, + }); + document.body.appendChild(liveRegion); + return liveRegion; +} + +export function announce(message: string, priority: keyof typeof AriaLiveRegion = 'polite'): void { + const liveRegion = createLiveRegion(priority); + liveRegion.textContent = message; + + // Remove the live region after a short delay + setTimeout(() => { + if (liveRegion.parentNode) { + liveRegion.parentNode.removeChild(liveRegion); + } + }, 1000); +} + +// Keyboard navigation +export const Keys = { + TAB: 'Tab', + ENTER: 'Enter', + SPACE: ' ', + ESCAPE: 'Escape', + ARROW_UP: 'ArrowUp', + ARROW_DOWN: 'ArrowDown', + ARROW_LEFT: 'ArrowLeft', + ARROW_RIGHT: 'ArrowRight', + HOME: 'Home', + END: 'End' +} as const; + +export function isKeyboardEvent(event: KeyboardEvent, key: keyof typeof Keys): boolean { + return event.key === Keys[key]; +} + +// Focus management +export function focusElement(selector: string): void { + const element = document.querySelector(selector); + if (element && element instanceof HTMLElement) { + element.focus(); + } +} + +export function focusFirstFocusable(container: HTMLElement | Document = document): void { + const firstFocusable = getFirstFocusable(container); + if (firstFocusable) { + firstFocusable.focus(); + } +} + +export function focusLastFocusable(container: HTMLElement | Document = document): void { + const lastFocusable = getLastFocusable(container); + if (lastFocusable) { + lastFocusable.focus(); + } +} + +// Skip links +export function setupSkipLinks(): void { + const skipLink = document.createElement('a'); + skipLink.href = '#main-content'; + skipLink.className = 'skip-link'; + skipLink.textContent = 'Skip to main content'; + skipLink.setAttribute('tabindex', '0'); + + // Add styles + const style = document.createElement('style'); + style.textContent = ` + .skip-link { + position: absolute; + top: -40px; + left: 0; + background: #000; + color: white; + padding: 8px; + z-index: 100; + transition: top 0.3s; + } + .skip-link:focus { + top: 0; + } + `; + + document.head.appendChild(style); + document.body.insertBefore(skipLink, document.body.firstChild); +} + +// Initialize accessibility features +if (typeof window !== 'undefined') { + document.addEventListener('DOMContentLoaded', () => { + setupSkipLinks(); + }); +} + +// Accessibility error handling +export class AccessibilityError extends Error { + constructor(message: string) { + super(message); + this.name = 'AccessibilityError'; + } +} + +// ARIA attributes helper +export function setAriaAttributes(element: HTMLElement, attributes: Record): void { + Object.entries(attributes).forEach(([key, value]) => { + if (value === null || value === false) { + element.removeAttribute(`aria-${key}`); + } else if (value === true) { + element.setAttribute(`aria-${key}`, 'true'); + } else { + element.setAttribute(`aria-${key}`, value); + } + }); +} + +// Focus trap for modals +export class FocusTrap { + private element: HTMLElement; + private firstFocusable: HTMLElement | null = null; + private lastFocusable: HTMLElement | null = null; + private boundKeydown: (e: KeyboardEvent) => void; + + constructor(element: HTMLElement) { + this.element = element; + this.boundKeydown = this.handleKeydown.bind(this); + this.init(); + } + + private init(): void { + const focusable = getFocusableElements(this.element); + if (focusable.length === 0) { + throw new AccessibilityError('No focusable elements found in the focus trap'); + } + + this.firstFocusable = focusable[0]; + this.lastFocusable = focusable[focusable.length - 1]; + + this.firstFocusable.focus(); + document.addEventListener('keydown', this.boundKeydown); + } + + private handleKeydown(event: KeyboardEvent): void { + if (event.key !== 'Tab') return; + + if (event.shiftKey) { + if (document.activeElement === this.firstFocusable) { + event.preventDefault(); + this.lastFocusable?.focus(); + } + } else { + if (document.activeElement === this.lastFocusable) { + event.preventDefault(); + this.firstFocusable?.focus(); + } + } + } + + public destroy(): void { + document.removeEventListener('keydown', this.boundKeydown); + } +} + +// Initialize accessibility features when the module is imported +if (typeof document !== 'undefined') { + setupSkipLinks(); +}