diff --git a/src/__tests__/keyboard-navigation.test.ts b/src/__tests__/keyboard-navigation.test.ts new file mode 100644 index 0000000..7ce0347 --- /dev/null +++ b/src/__tests__/keyboard-navigation.test.ts @@ -0,0 +1,117 @@ +/** + * Tests for keyboard navigation support (issue #662). + * + * Covers: + * - useKeyboardNavigation: Escape and Enter/Space handlers + * - useInteractiveKeyProps: key props for custom interactive components + * - useFocusTrap: Tab trapping logic + * - useFocusRestore: focus restoration on close + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { Platform } from 'react-native'; + +import { useKeyboardNavigation, useInteractiveKeyProps } from '../hooks/useKeyboardNavigation'; + +// Force web platform for keyboard tests +const originalOS = Platform.OS; +beforeAll(() => { + (Platform as any).OS = 'web'; + // Mock document for jsdom + if (typeof document === 'undefined') { + (global as any).document = { + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + activeElement: null, + }; + } +}); +afterAll(() => { + (Platform as any).OS = originalOS; +}); + +// ── useKeyboardNavigation ───────────────────────────────────────────────────── + +describe('useKeyboardNavigation', () => { + let addEventSpy: jest.SpyInstance; + let removeEventSpy: jest.SpyInstance; + + beforeEach(() => { + addEventSpy = jest.spyOn(document, 'addEventListener'); + removeEventSpy = jest.spyOn(document, 'removeEventListener'); + }); + + afterEach(() => { + addEventSpy.mockRestore(); + removeEventSpy.mockRestore(); + }); + + it('attaches keydown listener on web when enabled', () => { + renderHook(() => useKeyboardNavigation({ onEscape: jest.fn(), enabled: true })); + expect(addEventSpy).toHaveBeenCalledWith('keydown', expect.any(Function)); + }); + + it('calls onEscape when Escape key is pressed', () => { + const onEscape = jest.fn(); + renderHook(() => useKeyboardNavigation({ onEscape, enabled: true })); + + act(() => { + const handler = addEventSpy.mock.calls.find(([event]) => event === 'keydown')?.[1]; + handler?.({ key: 'Escape' }); + }); + + expect(onEscape).toHaveBeenCalledTimes(1); + }); + + it('does not call onEscape when disabled', () => { + const onEscape = jest.fn(); + renderHook(() => useKeyboardNavigation({ onEscape, enabled: false })); + + act(() => { + const handler = addEventSpy.mock.calls.find(([event]) => event === 'keydown')?.[1]; + handler?.({ key: 'Escape' }); + }); + + expect(onEscape).not.toHaveBeenCalled(); + }); + + it('removes keydown listener on unmount', () => { + const { unmount } = renderHook(() => + useKeyboardNavigation({ onEscape: jest.fn(), enabled: true }) + ); + unmount(); + expect(removeEventSpy).toHaveBeenCalledWith('keydown', expect.any(Function)); + }); +}); + +// ── useInteractiveKeyProps ──────────────────────────────────────────────────── + +describe('useInteractiveKeyProps', () => { + it('returns onKeyPress, tabIndex, and role props on web', () => { + const props = useInteractiveKeyProps(jest.fn()); + expect(props).toHaveProperty('onKeyPress'); + expect(props).toHaveProperty('tabIndex', 0); + expect(props).toHaveProperty('role', 'button'); + }); + + it('calls onPress when Enter is pressed', () => { + const onPress = jest.fn(); + const props = useInteractiveKeyProps(onPress) as any; + props.onKeyPress({ key: 'Enter', preventDefault: jest.fn() }); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('calls onPress when Space is pressed', () => { + const onPress = jest.fn(); + const props = useInteractiveKeyProps(onPress) as any; + props.onKeyPress({ key: ' ', preventDefault: jest.fn() }); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('does not call onPress for other keys', () => { + const onPress = jest.fn(); + const props = useInteractiveKeyProps(onPress) as any; + props.onKeyPress({ key: 'a', preventDefault: jest.fn() }); + expect(onPress).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/common/Drawer.tsx b/src/components/common/Drawer.tsx new file mode 100644 index 0000000..5a4d86e --- /dev/null +++ b/src/components/common/Drawer.tsx @@ -0,0 +1,169 @@ +import React, { useEffect, useRef } from 'react'; +import { + Animated, + Dimensions, + Modal, + Platform, + Pressable, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from 'react-native'; + +import { useFocusRestore } from '../../hooks/useFocusRestore'; +import { useFocusTrap } from '../../hooks/useFocusTrap'; +import { useKeyboardNavigation } from '../../hooks/useKeyboardNavigation'; + +export type DrawerPosition = 'left' | 'right' | 'bottom'; + +interface DrawerProps { + /** Whether the drawer is visible */ + visible: boolean; + /** Callback to close the drawer */ + onClose: () => void; + /** Side from which the drawer slides in */ + position?: DrawerPosition; + /** Accessibility label for screen readers */ + accessibilityLabel?: string; + /** Width of the drawer (left/right). Defaults to 80% of screen width. */ + width?: number; + /** Height of the drawer (bottom). Defaults to 50% of screen height. */ + height?: number; + /** Optional ref of the triggering element for focus restoration */ + triggerRef?: React.RefObject; + /** Style overrides for the drawer panel */ + drawerStyle?: StyleProp; + children: React.ReactNode; +} + +const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); + +/** + * Accessible slide-in drawer with keyboard navigation support. + * + * Keyboard behaviour (Web / iPad + Smart Keyboard): + * - Escape: closes the drawer + * - Tab / Shift+Tab: cycles focus within the drawer (focus trap) + * - Focus is restored to the triggering element on close + * + * WCAG 2.1 AA: satisfies 2.1.1 Keyboard, 2.4.3 Focus Order, 2.4.7 Focus Visible. + */ +export const Drawer: React.FC = ({ + visible, + onClose, + position = 'right', + accessibilityLabel = 'Drawer', + width, + height, + triggerRef, + drawerStyle, + children, +}) => { + const containerRef = useRef(null); + const translateAnim = useRef(new Animated.Value(0)).current; + + const drawerWidth = width ?? SCREEN_WIDTH * 0.8; + const drawerHeight = height ?? SCREEN_HEIGHT * 0.5; + + // Focus trap inside the drawer + useFocusRestore(visible, triggerRef); + const { containerProps, backgroundProps } = useFocusTrap(containerRef, visible, { + autoFocus: true, + }); + + // Escape key closes the drawer (web / tablet keyboard) + useKeyboardNavigation({ + enabled: visible, + onEscape: onClose, + }); + + // Slide animation + useEffect(() => { + const hiddenOffset = + position === 'bottom' ? drawerHeight : position === 'left' ? -drawerWidth : drawerWidth; + const toValue = visible ? 0 : hiddenOffset; + Animated.timing(translateAnim, { + toValue, + duration: 280, + useNativeDriver: true, + }).start(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible, position, drawerWidth, drawerHeight]); + + function getTransformStyle() { + if (position === 'bottom') { + return { transform: [{ translateY: translateAnim }] }; + } + return { transform: [{ translateX: translateAnim }] }; + } + + function getPositionStyle(): ViewStyle { + switch (position) { + case 'left': + return { left: 0, top: 0, bottom: 0, width: drawerWidth }; + case 'right': + return { right: 0, top: 0, bottom: 0, width: drawerWidth }; + case 'bottom': + return { left: 0, right: 0, bottom: 0, height: drawerHeight }; + } + } + + return ( + + {/* Backdrop */} + + + {/* Drawer panel */} + + + {children} + + + + ); +}; + +const styles = StyleSheet.create({ + backdrop: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + }, + drawer: { + position: 'absolute', + backgroundColor: '#ffffff', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 8, + elevation: 8, + }, + inner: { + flex: 1, + }, +}); diff --git a/src/components/common/PrimaryButton.tsx b/src/components/common/PrimaryButton.tsx index e5376fb..5c38bce 100644 --- a/src/components/common/PrimaryButton.tsx +++ b/src/components/common/PrimaryButton.tsx @@ -4,9 +4,9 @@ import { TouchableOpacity, Text, ActivityIndicator, - View, ViewStyle, TextStyle, + Platform, } from 'react-native'; import { useDynamicFontSize } from '../../hooks'; @@ -35,6 +35,8 @@ interface PrimaryButtonProps { icon?: React.ReactNode; accessibilityHint?: string; accessibilityLabel?: string; + /** Test ID for automated tests */ + testID?: string; } const PrimaryButton = ({ @@ -88,6 +90,17 @@ const PrimaryButton = ({ accessibilityLabel={buttonLabel} accessibilityHint={accessibilityHint} accessibilityState={{ disabled: isDisabled, busy: loading }} + {...Platform.select({ + web: { + // WCAG 2.4.7: Focus Visible — show outline on keyboard focus + style: [ + { opacity: isDisabled ? 0.6 : 1 }, + style, + { outlineStyle: 'auto', outlineColor: '#586ce9', outlineOffset: 2 }, + ] as any, + } as any, + default: {}, + })} > ); -} +}; export default memo(PrimaryButton); diff --git a/src/hooks/useKeyboardNavigation.ts b/src/hooks/useKeyboardNavigation.ts new file mode 100644 index 0000000..9cda6e6 --- /dev/null +++ b/src/hooks/useKeyboardNavigation.ts @@ -0,0 +1,77 @@ +import { useEffect, useCallback } from 'react'; +import { Platform } from 'react-native'; + +/** + * useKeyboardNavigation + * + * Adds global keyboard navigation support for web and tablet targets. + * + * - Escape: calls onEscape (close modals, drawers, dropdowns) + * - Enter / Space: calls onActivate (activate focused interactive element) + * + * Only attaches listeners on web (Platform.OS === 'web'). + * Safe to use on native — no-op when not on web. + * + * WCAG 2.1 AA: satisfies 2.1.1 Keyboard and 2.1.2 No Keyboard Trap. + */ +export function useKeyboardNavigation(options: { + onEscape?: () => void; + onActivate?: () => void; + enabled?: boolean; +}) { + const { onEscape, onActivate, enabled = true } = options; + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!enabled) return; + + switch (e.key) { + case 'Escape': + onEscape?.(); + break; + case 'Enter': + case ' ': + // Only activate if the focused element is NOT a native input/button + // (those handle Enter/Space natively) + if ( + document.activeElement && + !['INPUT', 'BUTTON', 'TEXTAREA', 'SELECT', 'A'].includes(document.activeElement.tagName) + ) { + onActivate?.(); + } + break; + } + }, + [enabled, onEscape, onActivate] + ); + + useEffect(() => { + if (Platform.OS !== 'web' || !enabled) return; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [enabled, handleKeyDown]); +} + +/** + * Returns onKeyPress props for custom interactive components that need + * Enter/Space activation (e.g. custom Pressable cards, list items). + * + * Usage: + * const keyProps = useInteractiveKeyProps(onPress); + * ... + */ +export function useInteractiveKeyProps(onPress?: () => void) { + if (Platform.OS !== 'web') return {}; + + return { + onKeyPress: (e: any) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onPress?.(); + } + }, + tabIndex: 0, + role: 'button' as const, + }; +}