Files
v10/.claude/skills/aria/references/react.md
T

480 lines
9.6 KiB
Markdown

# React Accessibility Patterns
React-specific patterns for accessibility. Covers hook architecture, ref management, and framework considerations.
## Contents
- [Hook Architecture](#hook-architecture) — Layer separation, behavior hooks
- [Focus Management Hooks](#focus-management-hooks) — Scope, ring detection, roving tabindex
- [Ref Patterns](#ref-patterns) — Merging refs, callback refs
- [Event Handling](#event-handling) — Keyboard normalization, press events
- [Announcements](#announcements) — Live regions, status messages
- [SSR Considerations](#ssr-considerations) — ID generation, hydration
- [Portal Accessibility](#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
```typescript
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:
```typescript
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:
```typescript
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
```typescript
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:
```typescript
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
```typescript
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:
```typescript
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:
```typescript
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
```typescript
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
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
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
```typescript
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)
```typescript
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
```typescript
// 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
```typescript
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
```typescript
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
```typescript
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
```typescript
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](../../component/references/react.md) — React component architecture (context, controlled state, render props)