mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
9.6 KiB
9.6 KiB
React Accessibility Patterns
React-specific patterns for accessibility. Covers hook architecture, ref management, and framework considerations.
Contents
- Hook Architecture — Layer separation, behavior hooks
- Focus Management Hooks — Scope, ring detection, roving tabindex
- Ref Patterns — Merging refs, callback refs
- Event Handling — Keyboard normalization, press events
- Announcements — Live regions, status messages
- SSR Considerations — ID generation, hydration
- Portal Accessibility
Hook Architecture
Separate accessibility logic into composable hooks:
Layer Separation
| Layer | Responsibility | Example |
|---|---|---|
| State | Component data | useToggleState |
| Behavior | ARIA + events | useButton, useSlider |
| Render | DOM output | Component JSX |
This separation allows:
- Sharing accessibility logic across components
- Testing behavior independently
- Framework-agnostic core patterns
Behavior Hook Pattern
function useButton(props, ref) {
return {
buttonProps: {
role: 'button',
tabIndex: 0,
onKeyDown: handleKeyDown,
onClick: props.onPress,
'aria-disabled': props.isDisabled,
},
};
}
Focus Management Hooks
Focus Scope
Contain focus within a subtree:
function useFocusScope(options: {
contain?: boolean; // Trap focus
restoreFocus?: boolean; // Restore on unmount
autoFocus?: boolean; // Focus first element
}) {
const scopeRef = useRef(null);
const previousFocus = useRef(null);
// Implementation handles:
// - Finding focusable elements
// - Tab wrapping at boundaries
// - Restoring focus on unmount
return { scopeRef };
}
Focus Ring Detection
Detect keyboard vs pointer focus:
function useFocusRing() {
const [isFocusVisible, setFocusVisible] = useState(false);
// Track input modality globally
// Set true on keyboard events
// Set false on pointer events
return {
isFocusVisible,
focusProps: {
onFocus: () => {
/* check modality */
},
onBlur: () => setFocusVisible(false),
},
};
}
Roving Tabindex Hook
function useRovingTabindex(items: RefObject<HTMLElement>[]) {
const [activeIndex, setActiveIndex] = useState(0);
// Returns props to spread on each item
return items.map((ref, i) => ({
tabIndex: i === activeIndex ? 0 : -1,
onKeyDown: (e) => {
// Handle arrow keys, Home, End
// Update activeIndex
// Call focus() on new active item
},
}));
}
Ref Patterns
Merging Refs
When component accepts ref but you also need internal ref:
function useMergedRef<T>(...refs: Ref<T>[]): RefCallback<T> {
return useCallback((value) => {
refs.forEach(ref => {
if (typeof ref === 'function') {
ref(value);
} else if (ref) {
ref.current = value;
}
});
}, refs);
}
// Usage
const Component = forwardRef((props, forwardedRef) => {
const internalRef = useRef(null);
const ref = useMergedRef(forwardedRef, internalRef);
return <div ref={ref} />;
});
Callback Refs for Dynamic Elements
function useCallbackRef<T>(callback: (node: T | null) => void) {
const ref = useRef(callback);
useLayoutEffect(() => {
ref.current = callback;
});
return useCallback((node: T | null) => {
ref.current(node);
}, []);
}
Event Handling
Keyboard Event Normalization
Handle cross-browser keyboard events:
function useKeyboard(handlers: { onKeyDown?: (e: KeyboardEvent) => void; onKeyUp?: (e: KeyboardEvent) => void }) {
return {
keyboardProps: {
onKeyDown: (e: ReactKeyboardEvent) => {
// Normalize key values
// Handle IME composition
// Call appropriate handler
},
},
};
}
Press Events
Unified press handling for mouse, touch, keyboard:
function usePress(props: {
onPress?: () => void;
onPressStart?: () => void;
onPressEnd?: () => void;
isDisabled?: boolean;
}) {
return {
pressProps: {
onClick: props.onPress,
onKeyDown: (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
props.onPress?.();
}
},
},
};
}
Announcements
Live Region Hook
function useAnnounce() {
const announce = useCallback((message: string, politeness: 'polite' | 'assertive' = 'polite') => {
// Get or create live region
// Clear existing content
// Set new content (triggers announcement)
}, []);
return { announce };
}
Status Message Pattern
function useFormValidation() {
const { announce } = useAnnounce();
const validate = (value) => {
const error = getError(value);
if (error) {
announce(error, 'assertive');
}
return error;
};
return { validate };
}
SSR Considerations
ID Generation
Generate stable IDs for ARIA relationships:
function useId(prefix?: string): string {
// Use React 18's useId if available
// Otherwise, generate stable ID
// Avoid hydration mismatches
}
// Usage
function Dialog({ title, children }) {
const titleId = useId('dialog-title');
return (
<div role="dialog" aria-labelledby={titleId}>
<h2 id={titleId}>{title}</h2>
{children}
</div>
);
}
Hydration Safety
Avoid client-only APIs in initial render:
function useSafeLayoutEffect(effect, deps) {
// useLayoutEffect on client
// useEffect (or skip) on server
const isClient = typeof window !== 'undefined';
const useIsomorphicEffect = isClient ? useLayoutEffect : useEffect;
useIsomorphicEffect(effect, deps);
}
Portal Accessibility
Focus Containment with Portals
Portaled content (modals, popovers) needs special handling:
function AccessiblePortal({ children, containFocus }) {
return createPortal(
<FocusScope contain={containFocus} restoreFocus autoFocus>
{children}
</FocusScope>,
document.body
);
}
ARIA Relationships Across Portals
When trigger and content are in different DOM locations:
function Popover({ trigger, content }) {
const triggerId = useId('trigger');
const contentId = useId('content');
return (
<>
<button
id={triggerId}
aria-controls={contentId}
aria-expanded={isOpen}
>
{trigger}
</button>
{isOpen && createPortal(
<div
id={contentId}
role="dialog"
aria-labelledby={triggerId}
>
{content}
</div>,
document.body
)}
</>
);
}
State Hook Patterns
Toggle State
function useToggleState(props: {
defaultSelected?: boolean;
isSelected?: boolean;
onChange?: (isSelected: boolean) => void;
}) {
const [isSelected, setSelected] = useControlledState(
props.isSelected,
props.defaultSelected ?? false,
props.onChange
);
return {
isSelected,
toggle: () => setSelected(!isSelected),
setSelected,
};
}
Selection State (Single/Multi)
function useSelectionState(props: {
selectionMode: 'none' | 'single' | 'multiple';
selectedKeys?: Set<Key>;
defaultSelectedKeys?: Set<Key>;
onSelectionChange?: (keys: Set<Key>) => void;
}) {
// Handles controlled/uncontrolled
// Enforces selection mode rules
// Provides select/deselect/toggle methods
}
Component Prop Patterns
Spreading Props Safely
// Separate ARIA/DOM props from component props
function splitProps<T>(props: T, ariaKeys: string[]) {
const ariaProps = {};
const restProps = {};
for (const key in props) {
if (ariaKeys.includes(key) || key.startsWith('aria-')) {
ariaProps[key] = props[key];
} else {
restProps[key] = props[key];
}
}
return [ariaProps, restProps];
}
Render Props for Flexibility
interface ButtonProps {
children: ReactNode | ((state: ButtonState) => ReactNode);
}
interface ButtonState {
isPressed: boolean;
isFocused: boolean;
isFocusVisible: boolean;
isDisabled: boolean;
}
// Allows consumers to access state for custom rendering
<Button>
{({ isPressed }) => (
<span className={isPressed ? 'pressed' : ''}>Click me</span>
)}
</Button>
Testing Patterns
Accessibility Testing Setup
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
test('component has no accessibility violations', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
Focus Testing
import { fireEvent } from '@testing-library/react';
test('arrow keys navigate options', () => {
const { getAllByRole } = render(<Listbox />);
const options = getAllByRole('option');
options[0].focus();
fireEvent.keyDown(options[0], { key: 'ArrowDown' });
expect(document.activeElement).toBe(options[1]);
});
Screen Reader Simulation
test('announces state changes', () => {
const { getByRole } = render(<Toggle />);
const button = getByRole('button');
const liveRegion = getByRole('status');
fireEvent.click(button);
expect(liveRegion).toHaveTextContent('Enabled');
});
See Also
- component/react.md — React component architecture (context, controlled state, render props)