From 70154eb9809084beeed6ad1ceab078cef44809fe Mon Sep 17 00:00:00 2001 From: K1NGD4VID Date: Fri, 29 May 2026 16:59:41 +0100 Subject: [PATCH] feat: implement accessible focus management hooks and AccessibleModal component --- docs/FOCUS_PATTERNS.md | 168 ++++++++++++++ jest.setup.js | 2 + src/components/common/AccessibleModal.tsx | 111 +++++++++ src/components/index.ts | 1 + src/components/mobile/AchievementBadges.tsx | 238 +++++++++----------- src/components/mobile/AvatarCamera.tsx | 17 +- src/components/mobile/BiometricPrompt.tsx | 18 +- src/components/mobile/FilterSheet.tsx | 23 +- src/hooks/index.ts | 2 + src/hooks/useFocusRestore.ts | 67 ++++++ src/hooks/useFocusTrap.ts | 150 ++++++++++++ tests/components/AccessibleModal.test.tsx | 29 +++ tests/hooks/useFocus.test.tsx | 68 ++++++ 13 files changed, 751 insertions(+), 143 deletions(-) create mode 100644 docs/FOCUS_PATTERNS.md create mode 100644 src/components/common/AccessibleModal.tsx create mode 100644 src/hooks/useFocusRestore.ts create mode 100644 src/hooks/useFocusTrap.ts create mode 100644 tests/components/AccessibleModal.test.tsx create mode 100644 tests/hooks/useFocus.test.tsx diff --git a/docs/FOCUS_PATTERNS.md b/docs/FOCUS_PATTERNS.md new file mode 100644 index 00000000..276dc842 --- /dev/null +++ b/docs/FOCUS_PATTERNS.md @@ -0,0 +1,168 @@ +# Focus Management and Accessibility Patterns + +Proper focus management is essential to deliver a highly accessible experience (conforming to WCAG AA guidelines) and prevent rendering/accessibility trees from displaying or reading out-of-order content. + +This document outlines the focus management hooks, the reusable `AccessibleModal` component, and general focus patterns implemented in TeachLink Mobile. + +--- + +## Why Focus Management Matters + +- **Keyboard Users (Web/Desktop/TV)**: Users navigating with a keyboard rely on the `Tab` and `Shift + Tab` keys to traverse the application. Without a focus trap, focus can leak outside of modal overlays and into elements underneath. +- **Screen Reader Users (VoiceOver/TalkBack)**: Users navigating with screen readers must have their focus programmatically shifted into modal structures upon activation, and their boundaries trapped within the modal card. +- **Context Preservation**: When dismissing a modal or drawer, focus must return to the element (e.g., button, card) that triggered it, so keyboard/screen reader users do not lose their current location in the application. + +--- + +## Focus Management Hooks + +### 1. `useFocusRestore` + +The `useFocusRestore` hook captures the currently focused element when a modal or interactive component becomes active, and automatically restores focus to that element when the component is deactivated or unmounted. + +#### Signature + +```typescript +export const useFocusRestore = ( + active: boolean, + triggerRef?: React.RefObject +) => void; +``` + +#### Usage Example + +```typescript +import { useFocusRestore } from '../hooks/useFocusRestore'; + +const MyModal = ({ visible, triggerRef }) => { + // Capture activeElement on mount/activation, restore focus on close/unmount + useFocusRestore(visible, triggerRef); + + return ( + + {/* Modal Contents */} + + ); +}; +``` + +### 2. `useFocusTrap` + +The `useFocusTrap` hook traps focus inside a container element, ensuring keyboard focus (`Tab` navigation) wraps around and screen readers are confined to the modal boundaries. + +It returns two sets of properties: + +- `containerProps`: To be spread onto the modal wrapper. +- `backgroundProps`: To be spread onto the screen elements outside the modal to hide them from the accessibility tree. + +#### Signature + +```typescript +export const useFocusTrap = ( + containerRef: React.RefObject, + active: boolean, + options?: { + initialFocusRef?: React.RefObject; + autoFocus?: boolean; + } +) => { + containerProps: object; + backgroundProps: object; +}; +``` + +#### Usage Example + +```typescript +import { useRef } from 'react'; +import { View } from 'react-native'; +import { useFocusTrap } from '../hooks/useFocusTrap'; + +const CustomOverlay = ({ visible }) => { + const containerRef = useRef(null); + const { containerProps } = useFocusTrap(containerRef, visible, { autoFocus: true }); + + return ( + + {/* Dialog content */} + + ); +}; +``` + +--- + +## Reusable Components + +### `AccessibleModal` + +A drop-in replacement for React Native's standard `Modal` that handles overlay dismissal, focus trapping, and focus restoration automatically. + +#### Props + +| Prop | Type | Description | +| -------------------- | ---------------------- | ------------------------------------------------------------ | +| `visible` | `boolean` | Controls visibility. | +| `onClose` | `() => void` | Invoked on backdrop press or hardware back/close request. | +| `accessibilityLabel` | `string` | Spoken label for screen readers. | +| `triggerRef` | `React.RefObject` | Optional ref of trigger element to restore focus to. | +| `initialFocusRef` | `React.RefObject` | Optional ref of element inside the modal to focus initially. | +| `overlayStyle` | `StyleProp` | Style for the backdrop. | +| `containerStyle` | `StyleProp` | Style for the modal card. | + +#### Usage Example + +```typescript +import { AccessibleModal } from '../components/common/AccessibleModal'; + +const App = () => { + const [modalOpen, setModalOpen] = useState(false); + const triggerButtonRef = useRef(null); + + return ( + + setModalOpen(true)} + > + Open Settings + + + setModalOpen(false)} + accessibilityLabel="App Settings" + triggerRef={triggerButtonRef} + > + Settings Details + setModalOpen(false)}> + Close + + + + ); +}; +``` + +--- + +## Testing & Verifying Focus Patterns + +1. **Unit Testing**: + Ensure focus hooks propagate the correct accessibility props (`accessibilityViewIsModal`, `aria-modal="true"`, etc.) and that focus restore triggers the target's `.focus()` function. + See [useFocus.test.tsx](file:///C:/Users/fuhad/teachLink_mobile/tests/hooks/useFocus.test.tsx). + +2. **Keyboard Navigation Verification (Web)**: + - Run the app on React Native Web. + - Open a modal. + - Press `Tab` repeatedly. Focus should wrap between elements _inside_ the modal and never leak to the background. + - Press `Shift + Tab`. Focus should cycle backwards within the modal. + +3. **Screen Reader Verification (Mobile)**: + - Enable VoiceOver (iOS) or TalkBack (Android). + - Open the modal. Ensure focus starts immediately inside the dialog. + - Swipe to navigate. The screen reader should only swipe through the modal's contents and not read elements behind the modal sheet. diff --git a/jest.setup.js b/jest.setup.js index 97a18e50..142bbd3e 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -18,6 +18,8 @@ jest.mock('react-native', () => ({ TextInput: 'TextInput', ActivityIndicator: 'ActivityIndicator', Image: 'Image', + Pressable: 'Pressable', + TouchableWithoutFeedback: 'TouchableWithoutFeedback', StyleSheet: { create: styles => styles, flatten: style => (style ? (Array.isArray(style) ? Object.assign({}, ...style) : style) : {}), diff --git a/src/components/common/AccessibleModal.tsx b/src/components/common/AccessibleModal.tsx new file mode 100644 index 00000000..379e69e9 --- /dev/null +++ b/src/components/common/AccessibleModal.tsx @@ -0,0 +1,111 @@ +import React, { useRef } from 'react'; +import { + Modal, + ModalProps, + View, + StyleSheet, + Pressable, + Platform, + StyleProp, + ViewStyle, +} from 'react-native'; + +import { useFocusRestore } from '../../hooks/useFocusRestore'; +import { useFocusTrap } from '../../hooks/useFocusTrap'; + +interface AccessibleModalProps extends Omit { + /** Whether the modal is visible */ + visible: boolean; + /** Callback when the modal overlay or request close is triggered */ + onClose: () => void; + /** Accessibility label for the screen reader */ + accessibilityLabel: string; + /** Style for the outer overlay backdrop */ + overlayStyle?: StyleProp; + /** Style for the inner modal card/container */ + containerStyle?: StyleProp; + /** Optional ref of the element that triggered the modal, for focus restoration on native */ + triggerRef?: React.RefObject; + /** Optional ref of the element inside the modal that should receive initial focus */ + initialFocusRef?: React.RefObject; + /** Contents of the modal */ + children: React.ReactNode; +} + +/** + * A reusable accessible modal component that wraps React Native's Modal + * and implements focus trapping (Tab trap on Web, screen reader trap on Native) + * and focus restoration (returns focus to the triggering element on dismissal). + */ +export const AccessibleModal: React.FC = ({ + visible, + onClose, + accessibilityLabel, + overlayStyle, + containerStyle, + triggerRef, + initialFocusRef, + children, + ...modalProps +}) => { + const containerRef = useRef(null); + + // Restore focus to the trigger element when the modal is closed/dismissed + useFocusRestore(visible, triggerRef); + + // Trap focus inside the modal container when visible + const { containerProps } = useFocusTrap(containerRef, visible, { + initialFocusRef, + autoFocus: true, + }); + + return ( + + + e.stopPropagation()} + {...containerProps} + > + {children} + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + justifyContent: 'center', + alignItems: 'center', + }, + content: { + width: '90%', + maxWidth: 400, + backgroundColor: '#fff', + borderRadius: 12, + padding: 20, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + ...Platform.select({ + web: { + outlineStyle: 'none', + } as any, + default: {}, + }), + }, +}); diff --git a/src/components/index.ts b/src/components/index.ts index b0d96c32..7f7ed581 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,6 +1,7 @@ export * from './mobile'; export * from './common/AppText'; +export * from './common/AccessibleModal'; export { ErrorBoundary } from './common/ErrorBoundary'; export type { ErrorBoundaryFallbackProps } from './common/ErrorBoundary'; export { default as PrimaryButton } from './common/PrimaryButton'; diff --git a/src/components/mobile/AchievementBadges.tsx b/src/components/mobile/AchievementBadges.tsx index 462ee037..572d9f8a 100644 --- a/src/components/mobile/AchievementBadges.tsx +++ b/src/components/mobile/AchievementBadges.tsx @@ -1,23 +1,17 @@ import { LinearGradient } from 'expo-linear-gradient'; import { Award, Lock, X } from 'lucide-react-native'; -import React, { useCallback, useState } from 'react'; -import { - Modal, - Pressable, - ScrollView, - StyleSheet, - Text, - TouchableOpacity, - View, -} from 'react-native'; +import React, { useCallback, useState, useRef } from 'react'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { AccessibleButton } from './AccessibleButton'; import { announceToScreenReader, combineAriaLabels, getAccessibilityProps, } from '../../utils/accessibility'; +import { AccessibleModal } from '../common/AccessibleModal'; import { ErrorBoundary } from '../common/ErrorBoundary'; import { CachedImage } from '../ui/CachedImage'; -import { AccessibleButton } from './AccessibleButton'; /** * Rarity levels for achievement badges @@ -77,11 +71,14 @@ export const AchievementBadges: React.FC = ({ isDark = false, }) => { const [selectedBadge, setSelectedBadge] = useState(null); + const badgeRefs = useRef<{ [key: string]: any }>({}); + const activeTriggerRef = useRef(null); const unlockedCount = achievements.filter(a => !a.isLocked).length; const totalCount = achievements.length; const handleSelectBadge = useCallback((achievement: Achievement) => { + activeTriggerRef.current = badgeRefs.current[achievement.id]; setSelectedBadge(achievement); announceToScreenReader(`Opening details for ${achievement.name} badge.`); }, []); @@ -103,6 +100,9 @@ export const AchievementBadges: React.FC = ({ return ( { + badgeRefs.current[achievement.id] = el; + }} onPress={() => handleSelectBadge(achievement)} style={styles.badgeWrapper} activeOpacity={0.8} @@ -218,140 +218,120 @@ export const AchievementBadges: React.FC = ({ )} {/* Badge detail modal */} - setSelectedBadge(null)} + onClose={() => setSelectedBadge(null)} accessibilityLabel="Achievement details" + overlayStyle={styles.modalOverlay} + containerStyle={[styles.modalCard, { backgroundColor: isDark ? '#1e293b' : '#fff' }]} + triggerRef={activeTriggerRef} > - setSelectedBadge(null)} - accessibilityLabel="Close modal" - accessibilityRole="button" + containerStyle={styles.modalClose} + activeOpacity={0.6} > - true} - onTouchEnd={(e: any) => e.stopPropagation()} - > - setSelectedBadge(null)} - containerStyle={styles.modalClose} - activeOpacity={0.6} - > - - - - {selectedBadge && - (() => { - const rarity: BadgeRarity = selectedBadge.rarity ?? 'common'; - const gradColors = RARITY_COLORS[rarity]; - return ( - <> - - {selectedBadge.isLocked ? ( - - - - ) : ( - - - {selectedBadge.emoji ?? '🏆'} - - - )} - - - - {selectedBadge.name} - + + + {selectedBadge && + (() => { + const rarity: BadgeRarity = selectedBadge.rarity ?? 'common'; + const gradColors = RARITY_COLORS[rarity]; + return ( + <> + + {selectedBadge.isLocked ? ( - - {RARITY_LABELS[rarity]} - + - - {selectedBadge.description && ( + ) : ( + - {selectedBadge.description} + {selectedBadge.emoji ?? '🏆'} - )} + + )} + - {selectedBadge.unlockedAt && ( - - Unlocked {selectedBadge.unlockedAt} - - )} + + {selectedBadge.name} + - {selectedBadge.progress && !selectedBadge.unlockedAt && ( - - - Progress: {selectedBadge.progress.current}/ - {selectedBadge.progress.total} - - - - - - )} - - ); - })()} - - + + + {RARITY_LABELS[rarity]} + + + + {selectedBadge.description && ( + + {selectedBadge.description} + + )} + + {selectedBadge.unlockedAt && ( + + Unlocked {selectedBadge.unlockedAt} + + )} + + {selectedBadge.progress && !selectedBadge.unlockedAt && ( + + + Progress: {selectedBadge.progress.current}/{selectedBadge.progress.total} + + + + + + )} + + ); + })()} - + ); }; diff --git a/src/components/mobile/AvatarCamera.tsx b/src/components/mobile/AvatarCamera.tsx index e3e5e8dd..583c63b3 100644 --- a/src/components/mobile/AvatarCamera.tsx +++ b/src/components/mobile/AvatarCamera.tsx @@ -1,6 +1,6 @@ import { LinearGradient } from 'expo-linear-gradient'; import { Camera, Check, ImageIcon, RefreshCw, X } from 'lucide-react-native'; -import React, { useState } from 'react'; +import React, { useState, useRef } from 'react'; import { ActivityIndicator, Modal, @@ -10,7 +10,8 @@ import { TouchableOpacity, View, } from 'react-native'; -import { useCamera } from '../../hooks'; + +import { useCamera, useFocusTrap, useFocusRestore } from '../../hooks'; import { ErrorBoundary } from '../common/ErrorBoundary'; import { CachedImage } from '../ui/CachedImage'; @@ -67,11 +68,21 @@ export const AvatarCamera: React.FC = ({ onClose(); }; + const containerRef = useRef(null); + useFocusRestore(visible); + const { containerProps } = useFocusTrap(containerRef, visible, { autoFocus: true }); + return ( - + {/* Header */} Update Profile Photo diff --git a/src/components/mobile/BiometricPrompt.tsx b/src/components/mobile/BiometricPrompt.tsx index a47298a7..6a95aaeb 100644 --- a/src/components/mobile/BiometricPrompt.tsx +++ b/src/components/mobile/BiometricPrompt.tsx @@ -1,5 +1,5 @@ import { Eye, FingerprintPattern, KeyRound, ScanFace } from 'lucide-react-native'; -import React from 'react'; +import React, { useRef } from 'react'; import { ActivityIndicator, Modal, @@ -9,6 +9,8 @@ import { TouchableOpacity, View, } from 'react-native'; + +import { useFocusTrap, useFocusRestore } from '../../hooks'; import { BiometricType } from '../../services/mobileAuth'; import { ErrorBoundary } from '../common/ErrorBoundary'; @@ -38,7 +40,7 @@ interface BiometricPromptProps { // ─── Biometric icon helper ──────────────────────────────────────────────────── -function BiometricIcon({ +const BiometricIcon = ({ type, size = 52, color, @@ -46,7 +48,7 @@ function BiometricIcon({ type: BiometricType; size?: number; color: string; -}) { +}) => { switch (type) { case 'face': return ; @@ -55,7 +57,7 @@ function BiometricIcon({ default: return ; } -} +}; function biometricLabel(type: BiometricType): string { switch (type) { @@ -90,6 +92,10 @@ export const BiometricPrompt: React.FC = ({ const label = biometricLabel(biometricType); + const containerRef = useRef(null); + useFocusRestore(visible); + const { containerProps } = useFocusTrap(containerRef, visible, { autoFocus: true }); + return ( = ({ > e.stopPropagation()} + accessibilityRole="dialog" + accessibilityLabel={`Sign in with ${label}`} + {...containerProps} > {/* Icon */} diff --git a/src/components/mobile/FilterSheet.tsx b/src/components/mobile/FilterSheet.tsx index 76809af0..29f809b4 100644 --- a/src/components/mobile/FilterSheet.tsx +++ b/src/components/mobile/FilterSheet.tsx @@ -1,5 +1,5 @@ import { Check, X } from 'lucide-react-native'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState, useRef } from 'react'; import { Dimensions, Modal, @@ -17,7 +17,8 @@ import Animated, { withTiming, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useAdaptiveFrameRate } from '../../hooks/useAdaptiveFrameRate'; + +import { useAdaptiveFrameRate, useFocusTrap, useFocusRestore } from '../../hooks'; import { ErrorBoundary } from '../common/ErrorBoundary'; const { height: SCREEN_HEIGHT } = Dimensions.get('window'); @@ -71,20 +72,24 @@ export interface FilterSheetProps { onReset?: () => void; } -export function FilterSheet({ +export const FilterSheet = ({ visible, onClose, filters, values, onApply, onReset, -}: FilterSheetProps) { +}: FilterSheetProps) => { const insets = useSafeAreaInsets(); const translateY = useSharedValue(SHEET_HEIGHT); const overlayOpacity = useSharedValue(0); const [localValues, setLocalValues] = useState(values); const { durationMultiplier } = useAdaptiveFrameRate(); + const containerRef = useRef(null); + useFocusRestore(visible); + const { containerProps } = useFocusTrap(containerRef, visible, { autoFocus: true }); + const open = useCallback(() => { translateY.value = withTiming(0, { duration: 280 * durationMultiplier }); overlayOpacity.value = withTiming(1, { duration: 280 * durationMultiplier }); @@ -135,6 +140,10 @@ export function FilterSheet({ ); -} +}; /** * Props for the FilterSection component @@ -198,7 +207,7 @@ interface FilterSectionProps { onSelect: (value: string) => void; } -function FilterSection({ label, options, selectedValue, onSelect }: FilterSectionProps) { +const FilterSection = ({ label, options, selectedValue, onSelect }: FilterSectionProps) => { return ( {label} @@ -221,7 +230,7 @@ function FilterSection({ label, options, selectedValue, onSelect }: FilterSectio ); -} +}; const styles = StyleSheet.create({ overlay: { diff --git a/src/hooks/index.ts b/src/hooks/index.ts index fa7cb82b..2a872bea 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -23,6 +23,8 @@ export * from './useScreenReader'; export * from './useSwipe'; export * from './useVideoGestures'; export * from './useVoiceRecognition'; +export * from './useFocusRestore'; +export * from './useFocusTrap'; // Optimized gesture handlers with native-driven animations export * from './useOptimizedLongPress'; diff --git a/src/hooks/useFocusRestore.ts b/src/hooks/useFocusRestore.ts new file mode 100644 index 00000000..60fae6ab --- /dev/null +++ b/src/hooks/useFocusRestore.ts @@ -0,0 +1,67 @@ +import { useEffect, useRef } from 'react'; +import { Platform, findNodeHandle, AccessibilityInfo } from 'react-native'; + +/** + * Hook to capture the currently focused element and restore focus to it. + * + * Can be triggered by: + * 1. Mounting/unmounting (using default active = true) + * 2. Toggling the active state (e.g. for components that stay mounted but change visibility) + * + * @param active Whether focus restore is active (defaults to true) + * @param triggerRef Optional ref of the element to restore focus to (if not the previously focused one) + */ +export const useFocusRestore = (active = true, triggerRef?: React.RefObject) => { + const previouslyFocusedRef = useRef(null); + + useEffect(() => { + if (active) { + if (Platform.OS === 'web') { + previouslyFocusedRef.current = document.activeElement; + } else { + // On native, we can allow the consumer to explicitly pass the triggering element ref + // or we try to find it if we have it. + if (triggerRef && triggerRef.current) { + previouslyFocusedRef.current = triggerRef.current; + } + } + } else { + // Deactivating: restore focus + restoreFocus(); + } + }, [active, triggerRef]); + + // Restore focus on unmount + useEffect(() => { + return () => { + restoreFocus(); + }; + }, []); + + const restoreFocus = () => { + if (previouslyFocusedRef.current) { + const element = previouslyFocusedRef.current; + previouslyFocusedRef.current = null; + + if (Platform.OS === 'web') { + if (typeof element.focus === 'function') { + element.focus(); + } + } else { + if (typeof element.focus === 'function') { + element.focus(); + } + + // Restore accessibility focus for screen readers + try { + const tag = findNodeHandle(element); + if (tag) { + AccessibilityInfo.setAccessibilityFocus(tag); + } + } catch { + // Ignore errors in environments where findNodeHandle is not supported (e.g. testing) + } + } + } + }; +}; diff --git a/src/hooks/useFocusTrap.ts b/src/hooks/useFocusTrap.ts new file mode 100644 index 00000000..1cdac813 --- /dev/null +++ b/src/hooks/useFocusTrap.ts @@ -0,0 +1,150 @@ +import { useEffect } from 'react'; +import { Platform, findNodeHandle, AccessibilityInfo } from 'react-native'; + +/** + * Hook to trap focus inside a container element. + * + * On Web: Traps Tab / Shift+Tab keyboard focus. + * On Native: Sets accessibility focus to the container or initial element, + * and helps manage screen reader boundaries. + * + * Returns helper accessibility props for wrapping elements. + */ +export const useFocusTrap = ( + containerRef: React.RefObject, + active = true, + options: { + initialFocusRef?: React.RefObject; + autoFocus?: boolean; + } = {} +) => { + const { initialFocusRef, autoFocus = true } = options; + + // On Web, handle focus trapping via keyboard listener + useEffect(() => { + if (!active || Platform.OS !== 'web' || !containerRef.current) return; + + const container = containerRef.current; + + // Selector for focusable elements on web + const focusableSelectors = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]', + '[focusable="true"]', + '[role="button"]', + '[role="link"]', + ].join(','); + + const getFocusableElements = (): HTMLElement[] => { + // Find all elements matching the selectors + const elements = Array.from(container.querySelectorAll(focusableSelectors)) as HTMLElement[]; + return elements.filter(el => { + if (el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true') { + return false; + } + const tabIndexAttr = el.getAttribute('tabindex'); + if (tabIndexAttr && parseInt(tabIndexAttr, 10) < 0) { + return false; + } + // Ensure element is visible + const style = window.getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') { + return false; + } + return true; + }); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return; + + const focusable = getFocusableElements(); + if (focusable.length === 0) { + e.preventDefault(); + return; + } + + const activeEl = document.activeElement as HTMLElement; + + const firstEl = focusable[0]; + const lastEl = focusable[focusable.length - 1]; + + if (e.shiftKey) { + // Shift + Tab: focus previous + if (activeEl === firstEl || !container.contains(activeEl)) { + lastEl.focus(); + e.preventDefault(); + } + } else { + // Tab: focus next + if (activeEl === lastEl || !container.contains(activeEl)) { + firstEl.focus(); + e.preventDefault(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + + // Initial focus on activation + if (autoFocus) { + const focusTarget = initialFocusRef?.current || getFocusableElements()[0] || container; + if (focusTarget && typeof focusTarget.focus === 'function') { + const timer = setTimeout(() => { + focusTarget.focus(); + }, 50); + return () => { + clearTimeout(timer); + document.removeEventListener('keydown', handleKeyDown); + }; + } + } + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [active, containerRef, initialFocusRef, autoFocus]); + + // On Native, handle initial focus and screen reader focus + useEffect(() => { + if (!active || Platform.OS === 'web' || !containerRef.current) return; + + if (autoFocus) { + const timer = setTimeout(() => { + const targetElement = initialFocusRef?.current || containerRef.current; + if (targetElement) { + if (typeof targetElement.focus === 'function') { + targetElement.focus(); + } + + try { + const tag = findNodeHandle(targetElement); + if (tag) { + AccessibilityInfo.setAccessibilityFocus(tag); + } + } catch { + // Ignore errors in environments where findNodeHandle is not supported + } + } + }, 100); + + return () => clearTimeout(timer); + } + }, [active, containerRef, initialFocusRef, autoFocus]); + + return { + containerProps: { + accessible: true, + accessibilityViewIsModal: active, + 'aria-modal': active ? ('true' as const) : undefined, + }, + backgroundProps: { + accessibilityElementsHidden: active, + importantForAccessibility: active ? ('no-hide-descendants' as const) : ('auto' as const), + }, + }; +}; diff --git a/tests/components/AccessibleModal.test.tsx b/tests/components/AccessibleModal.test.tsx new file mode 100644 index 00000000..f2966bc6 --- /dev/null +++ b/tests/components/AccessibleModal.test.tsx @@ -0,0 +1,29 @@ +import { render } from '@testing-library/react-native'; +import React from 'react'; +import { Text } from 'react-native'; + +import { AccessibleModal } from '../../src/components/common/AccessibleModal'; + +describe('AccessibleModal Component', () => { + it('renders children when visible is true', () => { + const { getByText } = render( + + Modal Content + + ); + + expect(getByText('Modal Content')).toBeTruthy(); + }); + + it('sets accessibility props correctly for dialog role', () => { + const { getByLabelText } = render( + + Content + + ); + + const dialog = getByLabelText('Test Dialog'); + expect(dialog.props.accessibilityRole).toBe('dialog'); + expect(dialog.props.accessibilityViewIsModal).toBe(true); + }); +}); diff --git a/tests/hooks/useFocus.test.tsx b/tests/hooks/useFocus.test.tsx new file mode 100644 index 00000000..603346fe --- /dev/null +++ b/tests/hooks/useFocus.test.tsx @@ -0,0 +1,68 @@ +import { renderHook } from '@testing-library/react-native'; + +import { useFocusRestore } from '../../src/hooks/useFocusRestore'; +import { useFocusTrap } from '../../src/hooks/useFocusTrap'; + +describe('Focus Management Hooks', () => { + describe('useFocusRestore', () => { + it("initializes correctly and doesn't throw", () => { + const { result } = renderHook(() => useFocusRestore(false)); + expect(result).toBeDefined(); + }); + + it('captures and restores focus when active toggles', () => { + const mockFocus = jest.fn(); + const mockRef = { + current: { + focus: mockFocus, + }, + }; + + const { rerender } = renderHook(({ active, ref }) => useFocusRestore(active, ref), { + initialProps: { active: false, ref: mockRef }, + }); + + // Toggle active to true (simulate modal opening) + rerender({ active: true, ref: mockRef }); + expect(mockFocus).not.toHaveBeenCalled(); + + // Toggle active to false (simulate modal closing) + rerender({ active: false, ref: mockRef }); + expect(mockFocus).toHaveBeenCalled(); + }); + }); + + describe('useFocusTrap', () => { + it('returns proper accessibility properties', () => { + const containerRef = { current: {} }; + const { result } = renderHook(() => useFocusTrap(containerRef, true)); + + expect(result.current.containerProps).toEqual({ + accessible: true, + accessibilityViewIsModal: true, + 'aria-modal': 'true', + }); + + expect(result.current.backgroundProps).toEqual({ + accessibilityElementsHidden: true, + importantForAccessibility: 'no-hide-descendants', + }); + }); + + it('returns default background properties when inactive', () => { + const containerRef = { current: {} }; + const { result } = renderHook(() => useFocusTrap(containerRef, false)); + + expect(result.current.containerProps).toEqual({ + accessible: true, + accessibilityViewIsModal: false, + 'aria-modal': undefined, + }); + + expect(result.current.backgroundProps).toEqual({ + accessibilityElementsHidden: false, + importantForAccessibility: 'auto', + }); + }); + }); +});