Files
2nd/10_Wiki/Topics/Coding/Frontend_A11y_Modern.md
T
2026-05-10 22:08:15 +09:00

393 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: frontend-a11y-modern
title: Modern Accessibility — ARIA / focus / keyboard
category: Coding
status: draft
source_trust_level: B
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
tags: [frontend, accessibility, vibe-coding]
tech_stack: { language: "TS / React", applicable_to: ["Frontend"] }
applied_in: []
aliases: [a11y, accessibility, ARIA, focus management, keyboard nav, screen reader, WCAG]
---
# Modern Accessibility (a11y)
> 사용자 의 ~15% 가 disability. **WCAG 2.2, ARIA, focus, keyboard, screen reader**.
## 📖 핵심 개념
- Native HTML 가 best (button, anchor, label, ...).
- ARIA 가 last resort.
- Keyboard 가 first-class.
- Screen reader test.
## 💻 코드 패턴
### Semantic HTML
```html
<!---->
<div onclick='submit()'>Submit</div>
<!---->
<button type='submit'>Submit</button>
```
→ 90% 의 a11y 가 native.
### Heading hierarchy
```html
<h1>Page title</h1>
<h2>Section 1</h2>
<h3>Subsection</h3>
<h2>Section 2</h2>
```
→ Skip level 안 됨.
### Label
```html
<label for='email'>Email</label>
<input id='email' type='email' />
<!-- 또는 -->
<label>
Email
<input type='email' />
</label>
```
→ Screen reader 가 명확.
### ARIA (last resort)
```html
<div role='button' tabindex='0' onclick='...' onkeydown='...'>
Custom button
</div>
```
→ Native 가 안 가능 시 만.
### ARIA attributes
```html
<button aria-label='Close' aria-pressed='false'>×</button>
<input aria-describedby='hint' />
<span id='hint'>At least 8 characters</span>
<div aria-live='polite'>Status update</div>
<div aria-live='assertive'>Error: ...</div>
```
### Focus management
```ts
// Trap (modal)
function trapFocus(container: HTMLElement) {
const focusable = container.querySelectorAll('a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])');
const first = focusable[0] as HTMLElement;
const last = focusable[focusable.length - 1] as HTMLElement;
container.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
});
first.focus();
}
```
→ Modal / dialog 안 만 tab.
### Restore focus
```ts
function openModal() {
const previousFocus = document.activeElement as HTMLElement;
// ... show modal
// On close
previousFocus.focus();
}
```
### Skip link
```html
<a href='#main' class='skip-link'>Skip to content</a>
<style>
.skip-link {
position: absolute;
top: -40px;
left: 0;
}
.skip-link:focus { top: 0; }
</style>
```
→ Screen reader / keyboard user 가 navigation skip.
### Keyboard navigation
```
Tab: 다음 focusable.
Shift+Tab: 옛.
Enter / Space: button.
Escape: modal close.
Arrow: list / menu / radio.
```
```tsx
function Menu({ items }: { items: string[] }) {
const [activeIdx, setActiveIdx] = useState(0);
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') setActiveIdx(i => Math.min(items.length - 1, i + 1));
if (e.key === 'ArrowUp') setActiveIdx(i => Math.max(0, i - 1));
if (e.key === 'Enter') select(items[activeIdx]);
};
// ...
}
```
### Focus visible
```css
/* :focus 는 mouse 도 */
button:focus { outline: 2px solid blue; }
/* :focus-visible = keyboard 만 */
button:focus-visible { outline: 2px solid blue; }
button:focus:not(:focus-visible) { outline: none; }
```
### Color contrast
```
WCAG AA: 4.5:1 (normal text).
WCAG AAA: 7:1.
→ Tools: WebAIM contrast checker.
Tailwind 도 contrast warning.
```
### Reduced motion
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
```
→ Vestibular disorder.
### Reduced motion (JS)
```ts
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
```
### Screen reader test
```
macOS: VoiceOver (Cmd + F5).
Windows: NVDA (free) / JAWS.
iOS: VoiceOver.
Android: TalkBack.
→ Manual test 가 진짜.
```
### Auto test (axe-core)
```ts
import { axe } from 'jest-axe';
test('accessible', async () => {
const { container } = render(<MyComponent />);
expect(await axe(container)).toHaveNoViolations();
});
```
→ Automated audit.
### Lighthouse / Pa11y
```bash
lighthouse https://example.com --only-categories=accessibility
pa11y https://example.com
```
→ CI gate.
### React (a11y)
```tsx
// React 의 accessible component
<button aria-label='Close' onClick={close}>
<X />
</button>
// Form
<form>
<label htmlFor='email'>Email</label>
<input id='email' type='email' aria-required='true' />
{error && <p id='email-error' role='alert'>{error}</p>}
</form>
```
### Radix / shadcn (a11y baked in)
```tsx
import * as Dialog from '@radix-ui/react-dialog';
<Dialog.Root>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Content>...</Dialog.Content>
</Dialog.Root>
```
→ Focus trap, Escape, ARIA 자동.
→ [[Frontend_shadcn_Radix_Patterns]].
### Image alt
```html
<img src='hero.jpg' alt='Person typing on laptop' />
<!-- Decorative -->
<img src='divider.svg' alt='' role='presentation' />
```
### Form error
```html
<label for='email'>Email</label>
<input id='email' aria-invalid='true' aria-describedby='email-err' />
<span id='email-err' role='alert'>Required</span>
```
### Live region
```html
<div role='status' aria-live='polite'>
Item added to cart
</div>
<div role='alert' aria-live='assertive'>
Network error
</div>
```
→ Dynamic update 가 announce.
### Mobile a11y
```
iOS:
- VoiceOver, accessibilityLabel.
- Dynamic Type (font size).
Android:
- TalkBack, contentDescription.
- Talkback gesture.
React Native:
- accessibilityLabel.
- accessibilityHint.
- accessibilityRole.
```
### Modern HTML 5 elements
```html
<header>, <nav>, <main>, <article>, <section>, <aside>, <footer>
<dialog>, <details>, <summary>
```
→ Semantic + a11y free.
### Popover (modern)
```html
<button popovertarget='my-popover'>Open</button>
<div id='my-popover' popover='auto'>Content</div>
```
→ Native + a11y 자동.
### High contrast
```css
@media (prefers-contrast: more) {
/* high contrast styles */
}
@media (forced-colors: active) {
/* Windows high contrast */
}
```
### Cognitive a11y
```
- Clear language.
- Consistent navigation.
- Error prevention.
- Help text.
- Reasonable defaults.
→ Beyond visual / motor.
```
### Legal
```
ADA (US) — lawsuit risk.
EAA (EU 2025) — required.
WCAG 2.1 AA = baseline.
Section 508 (US gov) — required.
→ 큰 브랜드 = legal team 검증.
```
### CI gate
```yaml
- run: npx pa11y http://localhost:3000
- run: npx axe http://localhost:3000
- run: npx lighthouse-ci
```
### 함정
```
- onClick 가 div: keyboard X.
- ARIA 가 native 대신 사용: 잘못 가능.
- Focus 가 잃음: keyboard user lost.
- Color 만 information: blind 가 X.
- Auto-play: vestibular.
- Time limit: cognitive disability.
```
## 🤔 의사결정 기준
| 작업 | 추천 |
|---|---|
| Component | Native HTML > ARIA |
| Modal | Radix / shadcn |
| Form | Native label + aria-describedby |
| Animation | prefers-reduced-motion |
| Color | Contrast checker |
| Test | axe + manual screen reader |
| CI | pa11y / Lighthouse |
## ❌ 안티패턴
- **div + onClick**: keyboard X.
- **No label**: screen reader X.
- **Color 만 info**: blind 가 X.
- **No focus indicator**: keyboard X.
- **Modal 가 focus trap X**: lost.
- **Auto-play**: vestibular.
- **No alt**: image 의 의미 X.
## 🤖 LLM 활용 힌트
- Native HTML 가 best.
- Radix / shadcn 가 a11y baked in.
- axe + manual screen reader test.
- prefers-reduced-motion 항상.
## 🔗 관련 문서
- [[Frontend_A11y_Testing]]
- [[Frontend_shadcn_Radix_Patterns]]
- [[React_Accessibility_Patterns]]