Appearance
React Aria无障碍库 - 完整可访问组件库指南
1. React Aria简介
1.1 什么是React Aria
React Aria是Adobe开发的一套React Hooks库,提供了完整的可访问性解决方案,实现了ARIA规范和最佳实践。
typescript
const reactAriaFeatures = {
accessibility: [
'完整的ARIA支持',
'键盘导航',
'焦点管理',
'屏幕阅读器优化'
],
internationalization: [
'国际化日期时间',
'数字格式化',
'RTL布局支持',
'多语言支持'
],
interaction: [
'触摸手势',
'鼠标交互',
'键盘交互',
'跨平台支持'
],
components: [
'按钮',
'表单',
'对话框',
'菜单',
'列表',
'日期选择器',
'等等...'
]
};1.2 安装和设置
bash
# 核心库
npm install react-aria
# 或分别安装需要的包
npm install @react-aria/button
npm install @react-aria/dialog
npm install @react-aria/menu
npm install @react-aria/focus
# 状态管理(可选)
npm install @react-stately/button
npm install @react-stately/menu
# 类型定义
npm install @react-types/button @react-types/shared1.3 基础使用
tsx
import { useButton } from '@react-aria/button';
import { useRef } from 'react';
function Button(props) {
const ref = useRef<HTMLButtonElement>(null);
const { buttonProps } = useButton(props, ref);
return (
<button {...buttonProps} ref={ref}>
{props.children}
</button>
);
}
// 使用
<Button onPress={() => alert('Clicked')}>
点击我
</Button>2. 按钮组件
2.1 基础按钮
tsx
// Button.tsx
import { useButton } from '@react-aria/button';
import { AriaButtonProps } from '@react-types/button';
import { useRef } from 'react';
interface ButtonProps extends AriaButtonProps {
variant?: 'primary' | 'secondary' | 'danger';
}
export function Button({ variant = 'primary', ...props }: ButtonProps) {
const ref = useRef<HTMLButtonElement>(null);
const { buttonProps, isPressed } = useButton(props, ref);
return (
<button
{...buttonProps}
ref={ref}
className={`btn btn-${variant} ${isPressed ? 'pressed' : ''}`}
>
{props.children}
</button>
);
}2.2 切换按钮
tsx
// ToggleButton.tsx
import { useToggleButton } from '@react-aria/button';
import { useToggleState } from '@react-stately/toggle';
import { AriaToggleButtonProps } from '@react-types/button';
export function ToggleButton(props: AriaToggleButtonProps) {
const ref = useRef<HTMLButtonElement>(null);
const state = useToggleState(props);
const { buttonProps, isPressed } = useToggleButton(props, state, ref);
return (
<button
{...buttonProps}
ref={ref}
className={`
toggle-btn
${state.isSelected ? 'selected' : ''}
${isPressed ? 'pressed' : ''}
`}
>
{props.children}
{state.isSelected && <span aria-hidden="true">✓</span>}
</button>
);
}
// 使用
<ToggleButton
isSelected={isFavorite}
onChange={setIsFavorite}
aria-label="收藏"
>
<HeartIcon />
</ToggleButton>3. 表单组件
3.1 文本输入框
tsx
// TextField.tsx
import { useTextField } from '@react-aria/textfield';
import { AriaTextFieldProps } from '@react-types/textfield';
export function TextField(props: AriaTextFieldProps) {
const ref = useRef<HTMLInputElement>(null);
const { labelProps, inputProps, descriptionProps, errorMessageProps } =
useTextField(props, ref);
return (
<div className="text-field">
<label {...labelProps}>{props.label}</label>
<input {...inputProps} ref={ref} />
{props.description && (
<div {...descriptionProps} className="description">
{props.description}
</div>
)}
{props.errorMessage && (
<div {...errorMessageProps} className="error">
{props.errorMessage}
</div>
)}
</div>
);
}
// 使用
<TextField
label="邮箱"
type="email"
isRequired
description="我们不会分享你的邮箱"
errorMessage={errors.email}
value={email}
onChange={setEmail}
/>3.2 复选框
tsx
// Checkbox.tsx
import { useCheckbox } from '@react-aria/checkbox';
import { useToggleState } from '@react-stately/toggle';
import { AriaCheckboxProps } from '@react-types/checkbox';
export function Checkbox(props: AriaCheckboxProps) {
const ref = useRef<HTMLInputElement>(null);
const state = useToggleState(props);
const { inputProps } = useCheckbox(props, state, ref);
return (
<label className="checkbox">
<input {...inputProps} ref={ref} />
<span className={`checkbox-box ${state.isSelected ? 'checked' : ''}`}>
{state.isSelected && <CheckIcon />}
</span>
<span>{props.children}</span>
</label>
);
}
// 使用
<Checkbox
isSelected={agreed}
onChange={setAgreed}
>
我同意服务条款
</Checkbox>3.3 单选按钮组
tsx
// RadioGroup.tsx
import { useRadioGroup } from '@react-aria/radio';
import { useRadio } from '@react-aria/radio';
import { useRadioGroupState } from '@react-stately/radio';
import { AriaRadioGroupProps, AriaRadioProps } from '@react-types/radio';
// Radio组件
function Radio(props: AriaRadioProps) {
const ref = useRef<HTMLInputElement>(null);
const state = useContext(RadioContext);
const { inputProps } = useRadio(props, state, ref);
return (
<label className="radio">
<input {...inputProps} ref={ref} />
<span className="radio-circle">
{state.selectedValue === props.value && <span className="radio-dot" />}
</span>
<span>{props.children}</span>
</label>
);
}
// RadioGroup组件
const RadioContext = createContext(null);
export function RadioGroup(props: AriaRadioGroupProps) {
const state = useRadioGroupState(props);
const { radioGroupProps, labelProps } = useRadioGroup(props, state);
return (
<div {...radioGroupProps}>
<label {...labelProps}>{props.label}</label>
<RadioContext.Provider value={state}>
{props.children}
</RadioContext.Provider>
</div>
);
}
// 使用
<RadioGroup
label="选择配送方式"
value={shipping}
onChange={setShipping}
>
<Radio value="standard">标准配送 (免费)</Radio>
<Radio value="express">快速配送 (¥20)</Radio>
<Radio value="overnight">隔夜配送 (¥50)</Radio>
</RadioGroup>3.4 选择器
tsx
// Select.tsx
import { useSelect } from '@react-aria/select';
import { useSelectState } from '@react-stately/select';
import { AriaSelectProps } from '@react-types/select';
import { useButton } from '@react-aria/button';
import { useListBox, useOption } from '@react-aria/listbox';
export function Select<T extends object>(props: AriaSelectProps<T>) {
const state = useSelectState(props);
const ref = useRef<HTMLButtonElement>(null);
const {
labelProps,
triggerProps,
valueProps,
menuProps
} = useSelect(props, state, ref);
return (
<div className="select">
<label {...labelProps}>{props.label}</label>
<button
{...triggerProps}
ref={ref}
className="select-trigger"
>
<span {...valueProps}>
{state.selectedItem?.rendered || '请选择...'}
</span>
<span aria-hidden="true">▼</span>
</button>
{state.isOpen && (
<Popover>
<ListBox
{...menuProps}
state={state}
/>
</Popover>
)}
</div>
);
}
// ListBox组件
function ListBox({ state, ...props }) {
const ref = useRef<HTMLUListElement>(null);
const { listBoxProps } = useListBox(props, state, ref);
return (
<ul {...listBoxProps} ref={ref} className="listbox">
{[...state.collection].map(item => (
<Option key={item.key} item={item} state={state} />
))}
</ul>
);
}
// Option组件
function Option({ item, state }) {
const ref = useRef<HTMLLIElement>(null);
const { optionProps, isSelected, isFocused } = useOption(
{ key: item.key },
state,
ref
);
return (
<li
{...optionProps}
ref={ref}
className={`
option
${isSelected ? 'selected' : ''}
${isFocused ? 'focused' : ''}
`}
>
{item.rendered}
{isSelected && <span aria-hidden="true">✓</span>}
</li>
);
}
// 使用
<Select
label="选择国家"
items={countries}
selectedKey={country}
onSelectionChange={setCountry}
>
{item => <Item>{item.name}</Item>}
</Select>4. 对话框和模态框
4.1 模态对话框
tsx
// Dialog.tsx
import { useDialog } from '@react-aria/dialog';
import { useModal, useOverlay } from '@react-aria/overlays';
import { AriaDialogProps } from '@react-types/dialog';
export function Dialog({ title, children, ...props }: AriaDialogProps) {
const ref = useRef<HTMLDivElement>(null);
const { dialogProps, titleProps } = useDialog(props, ref);
return (
<div {...dialogProps} ref={ref} className="dialog">
<h2 {...titleProps}>{title}</h2>
{children}
</div>
);
}
// Modal组件
export function Modal({
isOpen,
onClose,
children
}: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
const { overlayProps, underlayProps } = useOverlay(
{ isOpen, onClose, isDismissable: true },
ref
);
const { modalProps } = useModal();
if (!isOpen) return null;
return (
<div className="modal-underlay" {...underlayProps}>
<div
{...overlayProps}
{...modalProps}
ref={ref}
className="modal"
>
{children}
</div>
</div>
);
}
// 使用
<Modal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<Dialog title="确认删除">
<p>确定要删除这个项目吗?</p>
<div className="dialog-actions">
<Button onPress={() => setIsOpen(false)}>取消</Button>
<Button variant="danger" onPress={handleDelete}>删除</Button>
</div>
</Dialog>
</Modal>4.2 弹出菜单
tsx
// Popover.tsx
import { useOverlay, DismissButton } from '@react-aria/overlays';
import { FocusScope } from '@react-aria/focus';
export function Popover({
isOpen,
onClose,
children,
...props
}: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
const { overlayProps } = useOverlay(
{
isOpen,
onClose,
shouldCloseOnBlur: true,
isDismissable: true
},
ref
);
if (!isOpen) return null;
return (
<FocusScope restoreFocus>
<div {...overlayProps} ref={ref} className="popover">
{children}
<DismissButton onDismiss={onClose} />
</div>
</FocusScope>
);
}5. 菜单组件
5.1 下拉菜单
tsx
// Menu.tsx
import { useMenu, useMenuItem } from '@react-aria/menu';
import { useMenuTrigger } from '@react-aria/menu';
import { useTreeState } from '@react-stately/tree';
import { useMenuTriggerState } from '@react-stately/menu';
// MenuItem组件
function MenuItem({ item, state, onAction }) {
const ref = useRef<HTMLLIElement>(null);
const { menuItemProps, isFocused, isPressed } = useMenuItem(
{ key: item.key, onAction },
state,
ref
);
return (
<li
{...menuItemProps}
ref={ref}
className={`
menu-item
${isFocused ? 'focused' : ''}
${isPressed ? 'pressed' : ''}
`}
>
{item.rendered}
</li>
);
}
// Menu组件
function Menu({ state, onAction, ...props }) {
const ref = useRef<HTMLUListElement>(null);
const { menuProps } = useMenu(props, state, ref);
return (
<ul {...menuProps} ref={ref} className="menu">
{[...state.collection].map(item => (
<MenuItem
key={item.key}
item={item}
state={state}
onAction={onAction}
/>
))}
</ul>
);
}
// MenuButton组件
export function MenuButton({ label, items, onAction }) {
const state = useMenuTriggerState({});
const ref = useRef<HTMLButtonElement>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger({}, state, ref);
const menuState = useTreeState({
selectionMode: 'none',
children: items
});
return (
<div className="menu-button">
<Button {...menuTriggerProps} ref={ref}>
{label}
<span aria-hidden="true">▼</span>
</Button>
{state.isOpen && (
<Popover isOpen={state.isOpen} onClose={state.close}>
<Menu
{...menuProps}
state={menuState}
onAction={onAction}
/>
</Popover>
)}
</div>
);
}
// 使用
<MenuButton
label="操作"
onAction={(key) => {
if (key === 'edit') handleEdit();
if (key === 'delete') handleDelete();
}}
>
<Item key="edit">编辑</Item>
<Item key="delete">删除</Item>
<Item key="duplicate">复制</Item>
</MenuButton>6. 列表组件
6.1 可选择列表
tsx
// ListView.tsx
import { useListBox, useOption } from '@react-aria/listbox';
import { useListState } from '@react-stately/list';
import { AriaListBoxProps } from '@react-types/listbox';
function Option({ item, state }) {
const ref = useRef<HTMLLIElement>(null);
const {
optionProps,
isSelected,
isFocused,
isDisabled
} = useOption({ key: item.key }, state, ref);
return (
<li
{...optionProps}
ref={ref}
className={`
list-option
${isSelected ? 'selected' : ''}
${isFocused ? 'focused' : ''}
${isDisabled ? 'disabled' : ''}
`}
>
{item.rendered}
{isSelected && <CheckIcon />}
</li>
);
}
export function ListView<T extends object>(props: AriaListBoxProps<T>) {
const state = useListState(props);
const ref = useRef<HTMLUListElement>(null);
const { listBoxProps } = useListBox(props, state, ref);
return (
<ul {...listBoxProps} ref={ref} className="list-view">
{[...state.collection].map(item => (
<Option key={item.key} item={item} state={state} />
))}
</ul>
);
}
// 使用
<ListView
aria-label="用户列表"
selectionMode="multiple"
selectedKeys={selectedUsers}
onSelectionChange={setSelectedUsers}
items={users}
>
{user => <Item>{user.name}</Item>}
</ListView>7. 焦点管理
7.1 FocusScope
tsx
// 焦点作用域
import { FocusScope } from '@react-aria/focus';
export function Dialog({ children }) {
return (
<FocusScope contain restoreFocus autoFocus>
<div role="dialog">
{children}
</div>
</FocusScope>
);
}
// 参数说明
const focusScopeProps = {
contain: true, // 焦点陷阱
restoreFocus: true, // 关闭时恢复焦点
autoFocus: true // 自动聚焦第一个元素
};7.2 useFocusRing
tsx
// 焦点指示器
import { useFocusRing } from '@react-aria/focus';
export function Button({ children }) {
const { isFocusVisible, focusProps } = useFocusRing();
return (
<button
{...focusProps}
className={isFocusVisible ? 'focus-visible' : ''}
>
{children}
</button>
);
}
// useFocusWithin - 检测内部焦点
import { useFocusWithin } from '@react-aria/interactions';
export function Form({ children }) {
const { focusWithinProps } = useFocusWithin({
onFocusWithin: () => console.log('表单获得焦点'),
onBlurWithin: () => console.log('表单失去焦点')
});
return (
<form {...focusWithinProps}>
{children}
</form>
);
}8. 交互Hook
8.1 usePress
tsx
// 处理点击/触摸/键盘交互
import { usePress } from '@react-aria/interactions';
export function Card({ onSelect }) {
const { pressProps, isPressed } = usePress({
onPress: () => onSelect(),
onPressStart: () => console.log('press start'),
onPressEnd: () => console.log('press end')
});
return (
<div
{...pressProps}
className={`card ${isPressed ? 'pressed' : ''}`}
tabIndex={0}
role="button"
>
卡片内容
</div>
);
}8.2 useHover
tsx
// 悬停交互
import { useHover } from '@react-aria/interactions';
export function Tooltip({ children, tooltip }) {
const { hoverProps, isHovered } = useHover({
onHoverStart: () => console.log('hover start'),
onHoverEnd: () => console.log('hover end')
});
return (
<div {...hoverProps}>
{children}
{isHovered && (
<div className="tooltip" role="tooltip">
{tooltip}
</div>
)}
</div>
);
}8.3 useLongPress
tsx
// 长按交互
import { useLongPress } from '@react-aria/interactions';
export function ContextMenuItem({ onContextMenu }) {
const { longPressProps } = useLongPress({
accessibilityDescription: '长按显示菜单',
onLongPressStart: () => onContextMenu(),
threshold: 500 // 500ms触发
});
return (
<div {...longPressProps}>
长按我
</div>
);
}9. 国际化
9.1 日期和时间
tsx
// DatePicker.tsx
import { useDatePicker } from '@react-aria/datepicker';
import { useDatePickerState } from '@react-stately/datepicker';
import { I18nProvider } from '@react-aria/i18n';
export function DatePicker(props) {
const state = useDatePickerState(props);
const ref = useRef<HTMLDivElement>(null);
const {
groupProps,
labelProps,
fieldProps,
buttonProps,
dialogProps,
calendarProps
} = useDatePicker(props, state, ref);
return (
<I18nProvider locale="zh-CN">
<div className="date-picker">
<label {...labelProps}>{props.label}</label>
<div {...groupProps} ref={ref}>
<DateField {...fieldProps} />
<Button {...buttonProps}>📅</Button>
</div>
{state.isOpen && (
<Popover isOpen onClose={state.close}>
<Dialog {...dialogProps}>
<Calendar {...calendarProps} />
</Dialog>
</Popover>
)}
</div>
</I18nProvider>
);
}9.2 数字格式化
tsx
// NumberField.tsx
import { useNumberField } from '@react-aria/numberfield';
import { useNumberFieldState } from '@react-stately/numberfield';
import { useLocale } from '@react-aria/i18n';
export function NumberField(props) {
const { locale } = useLocale();
const state = useNumberFieldState({ ...props, locale });
const ref = useRef<HTMLInputElement>(null);
const {
labelProps,
groupProps,
inputProps,
incrementButtonProps,
decrementButtonProps
} = useNumberField(props, state, ref);
return (
<div className="number-field">
<label {...labelProps}>{props.label}</label>
<div {...groupProps}>
<Button {...decrementButtonProps}>-</Button>
<input {...inputProps} ref={ref} />
<Button {...incrementButtonProps}>+</Button>
</div>
</div>
);
}
// 使用
<NumberField
label="价格"
formatOptions={{
style: 'currency',
currency: 'CNY'
}}
value={price}
onChange={setPrice}
/>10. 实用工具
10.1 useId
tsx
// 生成唯一ID
import { useId } from '@react-aria/utils';
export function FormField({ label }) {
const id = useId();
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} />
</>
);
}10.2 mergeProps
tsx
// 合并props
import { mergeProps } from '@react-aria/utils';
export function CustomButton(props) {
const { buttonProps } = useButton(props, ref);
const { hoverProps } = useHover({});
const { focusProps } = useFocusRing();
// 合并所有props
const allProps = mergeProps(buttonProps, hoverProps, focusProps);
return <button {...allProps}>{props.children}</button>;
}10.3 useObjectRef
tsx
// Ref管理
import { useObjectRef } from '@react-aria/utils';
export const Button = forwardRef((props, forwardedRef) => {
const ref = useObjectRef(forwardedRef);
const { buttonProps } = useButton(props, ref);
return <button {...buttonProps} ref={ref}>{props.children}</button>;
});11. 完整示例
11.1 可访问的表单
tsx
// AccessibleForm.tsx
import { useForm } from 'react-hook-form';
export function AccessibleForm() {
const { register, handleSubmit, formState: { errors } } = useForm();
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<TextField
label="姓名"
{...register('name', { required: '姓名不能为空' })}
errorMessage={errors.name?.message}
/>
<TextField
label="邮箱"
type="email"
{...register('email', {
required: '邮箱不能为空',
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: '邮箱格式不正确'
}
})}
errorMessage={errors.email?.message}
/>
<Select
label="国家"
{...register('country', { required: true })}
errorMessage={errors.country && '请选择国家'}
>
<Item key="cn">中国</Item>
<Item key="us">美国</Item>
<Item key="jp">日本</Item>
</Select>
<Checkbox {...register('terms', { required: true })}>
我同意服务条款
</Checkbox>
<Button type="submit">提交</Button>
</form>
);
}11.2 可访问的数据表格
tsx
// DataTable.tsx
import { useTable } from '@react-aria/table';
import { useTableState } from '@react-stately/table';
export function DataTable({ columns, rows }) {
const state = useTableState({
children: rows.map(row => (
<Row key={row.id}>
{columns.map(col => (
<Cell key={col.key}>{row[col.key]}</Cell>
))}
</Row>
))
});
const ref = useRef<HTMLTableElement>(null);
const { gridProps } = useTable({ 'aria-label': '数据表格' }, state, ref);
return (
<table {...gridProps} ref={ref}>
<thead>
<tr>
{columns.map(col => (
<th key={col.key} scope="col">
{col.name}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map(row => (
<tr key={row.id}>
{columns.map(col => (
<td key={col.key}>{row[col.key]}</td>
))}
</tr>
))}
</tbody>
</table>
);
}12. 测试
12.1 测试React Aria组件
typescript
// button.test.tsx
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Button', () => {
it('should handle press events', async () => {
const onPress = jest.fn();
const { getByRole } = render(
<Button onPress={onPress}>Click me</Button>
);
const button = getByRole('button');
await userEvent.click(button);
expect(onPress).toHaveBeenCalled();
});
it('should be keyboard accessible', async () => {
const onPress = jest.fn();
const { getByRole } = render(
<Button onPress={onPress}>Click me</Button>
);
const button = getByRole('button');
button.focus();
await userEvent.keyboard('{Enter}');
expect(onPress).toHaveBeenCalled();
});
});13. 最佳实践
typescript
const reactAriaBestPractices = {
usage: [
'优先使用React Aria提供的Hooks',
'结合Stately管理状态',
'使用mergeProps合并多个props',
'利用FocusScope管理焦点',
'使用I18nProvider国际化'
],
accessibility: [
'所有交互元素使用usePress而非onClick',
'为自定义组件添加适当的ARIA属性',
'使用useFocusRing提供焦点指示器',
'模态框使用useModal和useOverlay',
'表单使用专门的表单Hooks'
],
performance: [
'按需导入Hooks',
'避免不必要的重渲染',
'使用useObjectRef优化ref',
'合理使用useMemo和useCallback',
'大列表使用虚拟化'
],
customization: [
'保持可访问性同时自定义样式',
'使用className而非内联样式',
'支持主题定制',
'提供足够的视觉反馈',
'确保键盘和触摸交互一致'
]
};14. 与其他库对比
typescript
const libraryComparison = {
ReactAria: {
pros: [
'完整的可访问性支持',
'细粒度的Hooks',
'无样式,完全可定制',
'国际化支持',
'Adobe官方维护'
],
cons: [
'需要自己实现样式',
'学习曲线较陡',
'组件需要手动组装'
]
},
HeadlessUI: {
pros: [
'简单易用',
'Tailwind团队维护',
'开箱即用的组件'
],
cons: [
'功能相对有限',
'定制性不如React Aria',
'不支持国际化'
]
},
RadixUI: {
pros: [
'无样式组件',
'良好的可访问性',
'简洁的API'
],
cons: [
'组件数量较少',
'国际化支持有限'
]
}
};15. 总结
React Aria的关键优势:
- 完整可访问性: 实现了ARIA规范和最佳实践
- 细粒度控制: 提供底层Hooks,完全可定制
- 国际化: 内置国际化支持
- 跨平台: 支持Web、移动端、桌面
- 类型安全: 完整的TypeScript支持
- 无样式: 不限制UI设计
- 专业维护: Adobe官方团队维护
通过使用React Aria,可以快速构建可访问、国际化的React应用。