Loading...
Loading...
Implement web accessibility (a11y) standards following WCAG 2.1 guidelines. Use when building accessible UIs, fixing accessibility issues, or ensuring compliance with disability standards. Handles ARIA attributes, keyboard navigation, screen readers, semantic HTML, and accessibility testing.
npx skill4agent add supercent-io/skills-template web-accessibilityReact 모달 컴포넌트를 접근 가능하게 만들어줘:
- 프레임워크: React + TypeScript
- WCAG 레벨: AA
- 요구사항:
- 포커스 트랩 (모달 내부에만 포커스)
- ESC 키로 닫기
- 배경 클릭으로 닫기
- 스크린 리더에서 제목/설명 읽기<button><nav><main><header><footer><div><span><h1><h6><label><input><!-- ❌ 나쁜 예: div와 span만 사용 -->
<div class="header">
<span class="title">My App</span>
<div class="nav">
<div class="nav-item" onclick="navigate()">Home</div>
<div class="nav-item" onclick="navigate()">About</div>
</div>
</div>
<!-- ✅ 좋은 예: 시맨틱 HTML -->
<header>
<h1>My App</h1>
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
</header><!-- ❌ 나쁜 예: label 없음 -->
<input type="text" placeholder="Enter your name">
<!-- ✅ 좋은 예: label 연결 -->
<label for="name">Name:</label>
<input type="text" id="name" name="name" required>
<!-- 또는 label로 감싸기 -->
<label>
Email:
<input type="email" name="email" required>
</label>tabindextabindex="0"tabindex="-1"tabindex="1+"import React, { useState, useRef, useEffect } from 'react';
interface DropdownProps {
label: string;
options: { value: string; label: string }[];
onChange: (value: string) => void;
}
function AccessibleDropdown({ label, options, onChange }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const buttonRef = useRef<HTMLButtonElement>(null);
const listRef = useRef<HTMLUListElement>(null);
// 키보드 핸들러
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setSelectedIndex((prev) => (prev + 1) % options.length);
}
break;
case 'ArrowUp':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setSelectedIndex((prev) => (prev - 1 + options.length) % options.length);
}
break;
case 'Enter':
case ' ':
e.preventDefault();
if (isOpen) {
onChange(options[selectedIndex].value);
setIsOpen(false);
buttonRef.current?.focus();
} else {
setIsOpen(true);
}
break;
case 'Escape':
e.preventDefault();
setIsOpen(false);
buttonRef.current?.focus();
break;
}
};
return (
<div className="dropdown">
<button
ref={buttonRef}
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-labelledby="dropdown-label"
>
{label}
</button>
{isOpen && (
<ul
ref={listRef}
role="listbox"
aria-labelledby="dropdown-label"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
{options.map((option, index) => (
<li
key={option.value}
role="option"
aria-selected={index === selectedIndex}
onClick={() => {
onChange(option.value);
setIsOpen(false);
}}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
}aria-labelaria-labelledbyaria-describedbyaria-livearia-hiddenfunction AccessibleModal({ isOpen, onClose, title, children }) {
const modalRef = useRef<HTMLDivElement>(null);
// 모달 열릴 때 포커스 트랩
useEffect(() => {
if (isOpen) {
modalRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
ref={modalRef}
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === 'Escape') {
onClose();
}
}}
>
<div className="modal-overlay" onClick={onClose} aria-hidden="true" />
<div className="modal-content">
<h2 id="modal-title">{title}</h2>
<div id="modal-description">
{children}
</div>
<button onClick={onClose} aria-label="Close modal">
<span aria-hidden="true">×</span>
</button>
</div>
</div>
);
}function Notification({ message, type }: { message: string; type: 'success' | 'error' }) {
return (
<div
role="alert"
aria-live="assertive" // 즉시 알림 (error), "polite"는 순서대로 알림
aria-atomic="true" // 전체 내용 읽기
className={`notification notification-${type}`}
>
{type === 'error' && <span aria-label="Error">⚠️</span>}
{type === 'success' && <span aria-label="Success">✅</span>}
{message}
</div>
);
}/* ✅ 충분한 대비 (텍스트 #000 on #FFF = 21:1) */
.button {
background-color: #0066cc;
color: #ffffff; /* 대비율 7.7:1 */
}
/* ✅ 포커스 표시 */
button:focus,
a:focus {
outline: 3px solid #0066cc;
outline-offset: 2px;
}
/* ❌ outline: none 금지! */
button:focus {
outline: none; /* 절대 사용 금지 */
}
/* ✅ 색상 + 아이콘으로 상태 표시 */
.error-message {
color: #d32f2f;
border-left: 4px solid #d32f2f;
}
.error-message::before {
content: '⚠️';
margin-right: 8px;
}import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import AccessibleButton from './AccessibleButton';
expect.extend(toHaveNoViolations);
describe('AccessibleButton', () => {
it('should have no accessibility violations', async () => {
const { container } = render(
<AccessibleButton onClick={() => {}}>
Click Me
</AccessibleButton>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should be keyboard accessible', () => {
const handleClick = jest.fn();
const { getByRole } = render(
<AccessibleButton onClick={handleClick}>
Click Me
</AccessibleButton>
);
const button = getByRole('button');
// Enter 키
button.focus();
fireEvent.keyDown(button, { key: 'Enter' });
expect(handleClick).toHaveBeenCalled();
// Space 키
fireEvent.keyDown(button, { key: ' ' });
expect(handleClick).toHaveBeenCalledTimes(2);
});
});## Accessibility Checklist
### Semantic HTML
- [x] 시맨틱 HTML 태그 사용 (`<button>`, `<nav>`, `<main>` 등)
- [x] 제목 계층 구조 올바름 (h1 → h2 → h3)
- [x] 폼 라벨 모두 연결됨
### Keyboard Navigation
- [x] Tab으로 모든 인터랙티브 요소 접근 가능
- [x] Enter/Space로 버튼 활성화
- [x] ESC로 모달/드롭다운 닫기
- [x] 포커스 표시 명확 (outline)
### ARIA
- [x] `role` 적절히 사용
- [x] `aria-label` 또는 `aria-labelledby` 제공
- [x] 동적 콘텐츠에 `aria-live` 사용
- [x] 장식용 요소 `aria-hidden="true"`
### Visual
- [x] 색상 대비 WCAG AA 준수 (4.5:1)
- [x] 색상만으로 정보 전달 안 함
- [x] 텍스트 크기 조절 가능
- [x] 반응형 디자인
### Testing
- [x] axe DevTools 위반 사항 0
- [x] Lighthouse Accessibility 90+ 점수
- [x] 키보드 테스트 통과
- [x] 스크린 리더 테스트 완료altalt=""<label for="...">aria-labeloutline: nonefunction AccessibleContactForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
return (
<form onSubmit={handleSubmit} noValidate>
<h2 id="form-title">Contact Us</h2>
<p id="form-description">Please fill out the form below to get in touch.</p>
{/* 이름 */}
<div className="form-group">
<label htmlFor="name">
Name <span aria-label="required">*</span>
</label>
<input
type="text"
id="name"
name="name"
required
aria-required="true"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && (
<span id="name-error" role="alert" className="error">
{errors.name}
</span>
)}
</div>
{/* 이메일 */}
<div className="form-group">
<label htmlFor="email">
Email <span aria-label="required">*</span>
</label>
<input
type="email"
id="email"
name="email"
required
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : 'email-hint'}
/>
<span id="email-hint" className="hint">
We'll never share your email.
</span>
{errors.email && (
<span id="email-error" role="alert" className="error">
{errors.email}
</span>
)}
</div>
{/* 제출 버튼 */}
<button type="submit" disabled={submitStatus === 'loading'}>
{submitStatus === 'loading' ? 'Submitting...' : 'Submit'}
</button>
{/* 성공/실패 메시지 */}
{submitStatus === 'success' && (
<div role="alert" aria-live="polite" className="success">
✅ Form submitted successfully!
</div>
)}
{submitStatus === 'error' && (
<div role="alert" aria-live="assertive" className="error">
⚠️ An error occurred. Please try again.
</div>
)}
</form>
);
}function AccessibleTabs({ tabs }: { tabs: { id: string; label: string; content: React.ReactNode }[] }) {
const [activeTab, setActiveTab] = useState(0);
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
setActiveTab((index + 1) % tabs.length);
break;
case 'ArrowLeft':
e.preventDefault();
setActiveTab((index - 1 + tabs.length) % tabs.length);
break;
case 'Home':
e.preventDefault();
setActiveTab(0);
break;
case 'End':
e.preventDefault();
setActiveTab(tabs.length - 1);
break;
}
};
return (
<div>
{/* Tab List */}
<div role="tablist" aria-label="Content sections">
{tabs.map((tab, index) => (
<button
key={tab.id}
role="tab"
id={`tab-${tab.id}`}
aria-selected={activeTab === index}
aria-controls={`panel-${tab.id}`}
tabIndex={activeTab === index ? 0 : -1}
onClick={() => setActiveTab(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab.label}
</button>
))}
</div>
{/* Tab Panels */}
{tabs.map((tab, index) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={activeTab !== index}
tabIndex={0}
>
{tab.content}
</div>
))}
</div>
);
}<button><div role="button">#accessibility#a11y#WCAG#ARIA#screen-reader#keyboard-navigation#frontend