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
117 changes: 117 additions & 0 deletions src/__tests__/keyboard-navigation.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
169 changes: 169 additions & 0 deletions src/components/common/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -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<any>;
/** Style overrides for the drawer panel */
drawerStyle?: StyleProp<ViewStyle>;
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<DrawerProps> = ({
visible,
onClose,
position = 'right',
accessibilityLabel = 'Drawer',
width,
height,
triggerRef,
drawerStyle,
children,
}) => {
const containerRef = useRef<View>(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 (
<Modal
transparent
visible={visible}
animationType="none"
onRequestClose={onClose}
statusBarTranslucent
>
{/* Backdrop */}
<Pressable
style={styles.backdrop}
onPress={onClose}
accessible={false}
{...backgroundProps}
/>

{/* Drawer panel */}
<Animated.View style={[styles.drawer, getPositionStyle(), getTransformStyle(), drawerStyle]}>
<View
ref={containerRef}
style={styles.inner}
accessibilityRole="complementary"
accessibilityLabel={accessibilityLabel}
accessibilityViewIsModal
{...containerProps}
{...Platform.select({
web: {
// Visible focus outline for WCAG 2.4.7
style: [styles.inner, { outline: 'none' }] as any,
},
default: {},
})}
>
{children}
</View>
</Animated.View>
</Modal>
);
};

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,
},
});
17 changes: 15 additions & 2 deletions src/components/common/PrimaryButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import {
TouchableOpacity,
Text,
ActivityIndicator,
View,
ViewStyle,
TextStyle,
Platform,
} from 'react-native';

import { useDynamicFontSize } from '../../hooks';
Expand Down Expand Up @@ -35,6 +35,8 @@ interface PrimaryButtonProps {
icon?: React.ReactNode;
accessibilityHint?: string;
accessibilityLabel?: string;
/** Test ID for automated tests */
testID?: string;
}

const PrimaryButton = ({
Expand Down Expand Up @@ -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: {},
})}
>
<LinearGradient
colors={['#20afe7', '#2c8aec', '#586ce9']}
Expand Down Expand Up @@ -199,6 +212,6 @@ const PrimaryButton = ({
)}
</TouchableOpacity>
);
}
};

export default memo(PrimaryButton);
Loading
Loading