Skip to content

Commit e70a8bd

Browse files
Gaubeeclaude
andcommitted
♿ T024 A11y Screen Reader - ARIA labels, live regions, focus management
Enhances accessibility coverage from ~45% to ~70% with comprehensive screen reader support across core UI components. **S1 - ARIA Labels for Icon Buttons** - TabBar: Added `ariaLabel` prop to TabItem interface, localized via i18n - HomePage: Added labels to chain-selector and copy-address buttons - HistoryPage: Added labels to refresh and filter selects - TokenItem: Added label for clickable token rows **S2 - Live Regions for Form Validation** - AddressInput: Added `aria-live="polite"` + `aria-invalid` for real-time error feedback - PasswordInput: Added `aria-live="polite"` for strength indicator updates - Both components use `aria-describedby` to link error messages - Added semantic `role="alert"` for error text **S3 - Focus Management in BottomSheet** - Auto-focus first interactive element on open - Focus trap with Tab/Shift+Tab handling - Restore focus to trigger element on close - Added `tabIndex={-1}` to dialog container for programmatic focus **S4 - Skip Link Pattern** - Added keyboard-only skip-to-main link in AppLayout - Uses `.sr-only` + `focus:not-sr-only` pattern - Main element marked with `id="main-content"` and `tabIndex={-1}` **i18n Support** - Added 22 a11y translation keys across 4 locales (en/zh-CN/zh-TW/ar) - Updated test suites to use TestI18nProvider wrapper **Impact** 15 files changed, +284/-64 lines. All 1145 tests passing. Addresses WCAG 2.1 Level AA criteria for keyboard navigation and screen readers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent cc2d0ef commit e70a8bd

15 files changed

Lines changed: 284 additions & 64 deletions

File tree

‎src/components/layout/app-layout.tsx‎

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useEffect } from 'react'
22
import { useRouter } from '@tanstack/react-router'
3+
import { useTranslation } from 'react-i18next'
34
import { cn } from '@/lib/utils'
45
import { TabBar, type TabItem } from './tab-bar'
56
import { Home, Wallet, Settings, ArrowLeftRight } from 'lucide-react'
@@ -10,13 +11,6 @@ interface AppLayoutProps {
1011
className?: string
1112
}
1213

13-
const tabs: TabItem[] = [
14-
{ id: 'home', label: '首页', icon: <Home className="size-5" /> },
15-
{ id: 'transfer', label: '转账', icon: <ArrowLeftRight className="size-5" /> },
16-
{ id: 'wallet', label: '钱包', icon: <Wallet className="size-5" /> },
17-
{ id: 'settings', label: '设置', icon: <Settings className="size-5" /> },
18-
]
19-
2014
const tabRoutes: Record<string, string> = {
2115
home: '/',
2216
transfer: '/send',
@@ -36,6 +30,15 @@ export function AppLayout({ children, className }: AppLayoutProps) {
3630
const router = useRouter()
3731
const pathname = router.state.location.pathname
3832
const isInitialized = useWalletInitialized()
33+
const { t } = useTranslation()
34+
35+
// Build tabs with localized aria-labels
36+
const tabs: TabItem[] = [
37+
{ id: 'home', label: '首页', icon: <Home className="size-5" />, ariaLabel: t('a11y.tabHome') },
38+
{ id: 'transfer', label: '转账', icon: <ArrowLeftRight className="size-5" />, ariaLabel: t('a11y.tabTransfer') },
39+
{ id: 'wallet', label: '钱包', icon: <Wallet className="size-5" />, ariaLabel: t('a11y.tabWallet') },
40+
{ id: 'settings', label: '设置', icon: <Settings className="size-5" />, ariaLabel: t('a11y.tabSettings') },
41+
]
3942

4043
// 应用启动时初始化钱包 store
4144
useEffect(() => {
@@ -63,8 +66,16 @@ export function AppLayout({ children, className }: AppLayoutProps) {
6366

6467
return (
6568
<div className={cn('flex min-h-screen flex-col bg-background', className)}>
69+
{/* Skip link for keyboard navigation - S4 a11y */}
70+
<a
71+
href="#main-content"
72+
className="sr-only focus:not-sr-only focus:absolute focus:z-[100] focus:top-4 focus:left-4 focus:px-4 focus:py-2 focus:bg-primary focus:text-primary-foreground focus:rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
73+
>
74+
{t('a11y.skipToMain')}
75+
</a>
76+
6677
{/* 主内容区域 */}
67-
<main className="flex-1 overflow-auto pb-safe">
78+
<main id="main-content" className="flex-1 overflow-auto pb-safe" tabIndex={-1}>
6879
{children}
6980
</main>
7081

‎src/components/layout/bottom-sheet.tsx‎

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { useEffect, useRef } from 'react'
1+
import { useEffect, useRef, useCallback } from 'react'
22
import { cn } from '@/lib/utils'
3+
import { useTranslation } from 'react-i18next'
34

45
export interface BottomSheetProps {
56
open: boolean
@@ -16,6 +17,9 @@ const heightClasses = {
1617
full: 'h-[100vh]',
1718
}
1819

20+
// Focusable element selector
21+
const FOCUSABLE_SELECTOR = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
22+
1923
export function BottomSheet({
2024
open,
2125
onClose,
@@ -25,9 +29,13 @@ export function BottomSheet({
2529
className,
2630
}: BottomSheetProps) {
2731
const sheetRef = useRef<HTMLDivElement>(null)
32+
const previousActiveElementRef = useRef<HTMLElement | null>(null)
33+
const { t } = useTranslation()
2834

35+
// Store the previously focused element when opening
2936
useEffect(() => {
3037
if (open) {
38+
previousActiveElementRef.current = document.activeElement as HTMLElement | null
3139
document.body.style.overflow = 'hidden'
3240
} else {
3341
document.body.style.overflow = ''
@@ -37,15 +45,68 @@ export function BottomSheet({
3745
}
3846
}, [open])
3947

48+
// Focus first focusable element when sheet opens
4049
useEffect(() => {
41-
const handleEscape = (e: KeyboardEvent) => {
42-
if (e.key === 'Escape') onClose()
50+
if (open && sheetRef.current) {
51+
// Slight delay to ensure the sheet is fully rendered
52+
const timeoutId = setTimeout(() => {
53+
const firstFocusable = sheetRef.current?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)
54+
if (firstFocusable) {
55+
firstFocusable.focus()
56+
} else {
57+
// If no focusable element, focus the sheet itself
58+
sheetRef.current?.focus()
59+
}
60+
}, 50)
61+
return () => clearTimeout(timeoutId)
62+
}
63+
}, [open])
64+
65+
// Restore focus when sheet closes
66+
useEffect(() => {
67+
if (!open && previousActiveElementRef.current) {
68+
previousActiveElementRef.current.focus()
69+
previousActiveElementRef.current = null
70+
}
71+
}, [open])
72+
73+
// Handle keyboard navigation (Escape and Tab)
74+
const handleKeyDown = useCallback((e: KeyboardEvent) => {
75+
if (e.key === 'Escape') {
76+
onClose()
77+
return
4378
}
79+
80+
// Focus trap: keep focus within the sheet
81+
if (e.key === 'Tab' && sheetRef.current) {
82+
const focusableElements = sheetRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
83+
const firstFocusable = focusableElements[0]
84+
const lastFocusable = focusableElements[focusableElements.length - 1]
85+
86+
if (focusableElements.length === 0) return
87+
88+
if (e.shiftKey) {
89+
// Shift + Tab: if on first element, go to last
90+
if (document.activeElement === firstFocusable) {
91+
e.preventDefault()
92+
lastFocusable.focus()
93+
}
94+
} else {
95+
// Tab: if on last element, go to first
96+
if (document.activeElement === lastFocusable) {
97+
e.preventDefault()
98+
firstFocusable.focus()
99+
}
100+
}
101+
}
102+
}, [onClose])
103+
104+
useEffect(() => {
44105
if (open) {
45-
document.addEventListener('keydown', handleEscape)
106+
document.addEventListener('keydown', handleKeyDown)
46107
}
47-
return () => document.removeEventListener('keydown', handleEscape)
48-
}, [open, onClose])
108+
return () => document.removeEventListener('keydown', handleKeyDown)
109+
}, [open, handleKeyDown])
49110

50111
if (!open) return null
51112

@@ -63,9 +124,10 @@ export function BottomSheet({
63124
ref={sheetRef}
64125
role="dialog"
65126
aria-modal="true"
66-
aria-label={title ?? '弹窗'}
127+
aria-label={title ?? t('a11y.closeDialog')}
128+
tabIndex={-1}
67129
className={cn(
68-
'absolute bottom-0 left-0 right-0 bg-background rounded-t-2xl animate-slide-in-bottom safe-area-inset-bottom',
130+
'absolute bottom-0 left-0 right-0 bg-background rounded-t-2xl animate-slide-in-bottom safe-area-inset-bottom focus:outline-none',
69131
heightClasses[height],
70132
className
71133
)}

‎src/components/layout/tab-bar.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ export interface TabItem {
66
icon: React.ReactNode
77
activeIcon?: React.ReactNode
88
badge?: number | string
9+
/** Accessible label for screen readers (should be localized) */
10+
ariaLabel?: string
911
}
1012

1113
interface TabBarProps {
@@ -36,6 +38,7 @@ export function TabBar({ items, activeId, onTabChange, className }: TabBarProps)
3638
isActive ? 'text-primary' : 'text-muted hover:text-foreground'
3739
)}
3840
aria-current={isActive ? 'page' : undefined}
41+
aria-label={item.ariaLabel || item.label}
3942
>
4043
<span className="relative">
4144
{isActive && item.activeIcon ? item.activeIcon : item.icon}

‎src/components/security/password-input.test.tsx‎

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import { describe, it, expect, vi } from 'vitest'
22
import { render, screen } from '@testing-library/react'
33
import userEvent from '@testing-library/user-event'
44
import { PasswordInput, calculateStrength } from './password-input'
5+
import { TestI18nProvider } from '@/test/i18n-mock'
6+
7+
function renderWithProviders(ui: React.ReactElement) {
8+
return render(<TestI18nProvider>{ui}</TestI18nProvider>)
9+
}
510

611
describe('calculateStrength', () => {
712
it('returns weak for empty password', () => {
@@ -27,58 +32,58 @@ describe('calculateStrength', () => {
2732

2833
describe('PasswordInput', () => {
2934
it('renders password input', () => {
30-
render(<PasswordInput placeholder="Enter password" />)
35+
renderWithProviders(<PasswordInput placeholder="Enter password" />)
3136
expect(screen.getByPlaceholderText('Enter password')).toHaveAttribute('type', 'password')
3237
})
3338

3439
it('toggles password visibility', async () => {
35-
render(<PasswordInput placeholder="Enter password" />)
40+
renderWithProviders(<PasswordInput placeholder="Enter password" />)
3641
const input = screen.getByPlaceholderText('Enter password')
3742
const toggleButton = screen.getByRole('button', { name: '显示密码' })
38-
43+
3944
expect(input).toHaveAttribute('type', 'password')
40-
45+
4146
await userEvent.click(toggleButton)
4247
expect(input).toHaveAttribute('type', 'text')
4348
expect(screen.getByRole('button', { name: '隐藏密码' })).toBeInTheDocument()
44-
49+
4550
await userEvent.click(screen.getByRole('button', { name: '隐藏密码' }))
4651
expect(input).toHaveAttribute('type', 'password')
4752
})
4853

4954
it('shows strength indicator when enabled', async () => {
50-
render(<PasswordInput showStrength />)
55+
renderWithProviders(<PasswordInput showStrength />)
5156
const input = document.querySelector('input')!
52-
57+
5358
await userEvent.type(input, 'test')
5459
expect(screen.getByText('密码强度:')).toBeInTheDocument()
5560
})
5661

5762
it('calls onChange handler', async () => {
5863
const handleChange = vi.fn()
59-
render(<PasswordInput onChange={handleChange} />)
60-
64+
renderWithProviders(<PasswordInput onChange={handleChange} />)
65+
6166
const input = document.querySelector('input')!
6267
await userEvent.type(input, 'test')
6368
expect(handleChange).toHaveBeenCalled()
6469
})
6570

6671
it('calls onStrengthChange when strength changes', async () => {
6772
const handleStrengthChange = vi.fn()
68-
render(<PasswordInput showStrength onStrengthChange={handleStrengthChange} />)
69-
73+
renderWithProviders(<PasswordInput showStrength onStrengthChange={handleStrengthChange} />)
74+
7075
const input = document.querySelector('input')!
7176
await userEvent.type(input, 'MyP@ssw0rd!')
7277
expect(handleStrengthChange).toHaveBeenCalled()
7378
})
7479

7580
it('is disabled when disabled prop is true', () => {
76-
render(<PasswordInput disabled />)
81+
renderWithProviders(<PasswordInput disabled />)
7782
expect(document.querySelector('input')).toBeDisabled()
7883
})
7984

8085
it('applies custom className', () => {
81-
render(<PasswordInput className="custom-class" />)
86+
renderWithProviders(<PasswordInput className="custom-class" />)
8287
expect(document.querySelector('input')).toHaveClass('custom-class')
8388
})
8489
})

‎src/components/security/password-input.tsx‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState, forwardRef } from 'react'
22
import { cn } from '@/lib/utils'
3+
import { useTranslation } from 'react-i18next'
34
import { Eye, EyeOff } from 'lucide-react'
45

56
interface PasswordInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
@@ -35,6 +36,7 @@ const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
3536
const [visible, setVisible] = useState(false)
3637
const [strength, setStrength] = useState<PasswordStrength>('weak')
3738
const [hasValue, setHasValue] = useState(!!value)
39+
const { t } = useTranslation()
3840

3941
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
4042
const inputValue = e.target.value
@@ -71,7 +73,7 @@ const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
7173
onClick={() => setVisible(!visible)}
7274
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted hover:text-foreground transition-colors"
7375
tabIndex={-1}
74-
aria-label={visible ? '隐藏密码' : '显示密码'}
76+
aria-label={visible ? t('a11y.hidePassword') : t('a11y.showPassword')}
7577
>
7678
{visible ? (
7779
<EyeOff className="size-5" />
@@ -82,18 +84,19 @@ const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
8284
</div>
8385

8486
{showStrength && hasValue && (
85-
<div className="space-y-1">
86-
<div className="h-1 w-full rounded-full bg-muted overflow-hidden">
87-
<div
87+
<div className="space-y-1" aria-live="polite" aria-atomic="true">
88+
<div className="h-1 w-full rounded-full bg-muted overflow-hidden" role="progressbar" aria-valuenow={strength === 'weak' ? 33 : strength === 'medium' ? 66 : 100} aria-valuemin={0} aria-valuemax={100}>
89+
<div
8890
className={cn('h-full transition-all duration-300', config.color, config.width)}
8991
/>
9092
</div>
9193
<p className="text-xs text-muted">
92-
密码强度:<span className={cn(
94+
<span className="sr-only">{t('a11y.passwordStrength', { strength: config.label })}</span>
95+
<span aria-hidden="true">密码强度:<span className={cn(
9396
strength === 'weak' && 'text-destructive',
9497
strength === 'medium' && 'text-yellow-500',
9598
strength === 'strong' && 'text-secondary',
96-
)}>{config.label}</span>
99+
)}>{config.label}</span></span>
97100
</p>
98101
</div>
99102
)}

‎src/components/token/token-item.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { cn } from '@/lib/utils'
2+
import { useTranslation } from 'react-i18next'
23
import { ChainIcon, type ChainType } from '../wallet/chain-icon'
34
import { AmountDisplay } from '../common'
45

@@ -21,13 +22,15 @@ interface TokenItemProps {
2122

2223
export function TokenItem({ token, onClick, showChange = false, className }: TokenItemProps) {
2324
const isClickable = !!onClick
25+
const { t } = useTranslation()
2426

2527
return (
2628
<div
2729
role={isClickable ? 'button' : undefined}
2830
tabIndex={isClickable ? 0 : undefined}
2931
onClick={onClick}
3032
onKeyDown={isClickable ? (e) => e.key === 'Enter' && onClick?.() : undefined}
33+
aria-label={isClickable ? t('a11y.tokenDetails', { token: token.symbol }) : undefined}
3134
className={cn(
3235
'flex items-center gap-3 p-3 rounded-xl transition-colors @container',
3336
isClickable && 'cursor-pointer hover:bg-muted/50 active:bg-muted',

0 commit comments

Comments
 (0)