mirror of
https://github.com/vercel/components.build.git
synced 2026-09-14 20:06:39 +08:00
55fae375a4
* Update Geistdocs * Update geistdocs.tsx * Run translation script * Delete toc.tsx * Update route.ts * Update route.ts
811 lines
18 KiB
Plaintext
811 lines
18 KiB
Plaintext
---
|
||
title: Aksesibilitas
|
||
description: Membangun komponen yang dapat digunakan oleh semua orang, termasuk pengguna dengan disabilitas yang mengandalkan teknologi bantu.
|
||
---
|
||
|
||
Aksesibilitas (a11y) bukanlah fitur opsional—itu adalah persyaratan mendasar untuk komponen web modern. Setiap komponen harus dapat digunakan oleh semua orang, termasuk orang dengan disabilitas visual, motorik, pendengaran, atau kognitif.
|
||
|
||
Panduan ini adalah daftar prinsip dan pola aksesibilitas yang tidak lengkap yang harus Anda ikuti saat membangun komponen. Ini bukan panduan komprehensif, tetapi seharusnya memberi Anda gambaran tentang jenis masalah yang harus Anda waspadai.
|
||
|
||
Jika Anda menggunakan linter dengan aturan aksesibilitas yang ketat seperti [Ultracite](https://www.ultracite.ai), jenis masalah ini kemungkinan akan terdeteksi secara otomatis, tetapi tetap penting untuk memahami prinsip-prinsipnya.
|
||
|
||
## Prinsip Inti
|
||
|
||
### 1. Utamakan HTML Semantik
|
||
|
||
Selalu mulai dengan elemen HTML yang paling sesuai. HTML semantik menyediakan fitur aksesibilitas bawaan yang sering terlewat oleh implementasi kustom.
|
||
|
||
```tsx
|
||
// ❌ Don't reinvent the wheel
|
||
<div onClick={handleClick} className="button">
|
||
Click me
|
||
</div>
|
||
|
||
// ✅ Use semantic elements
|
||
<button onClick={handleClick}>
|
||
Click me
|
||
</button>
|
||
```
|
||
|
||
Elemen semantik menyertakan pengumuman peran yang tepat, interaksi keyboard, manajemen fokus, dan partisipasi dalam form.
|
||
|
||
### 2. Navigasi Keyboard
|
||
|
||
Setiap elemen interaktif harus dapat diakses dengan keyboard. Pengguna harus dapat menavigasi, mengaktifkan, dan berinteraksi dengan semua fungsi hanya menggunakan keyboard.
|
||
|
||
```tsx
|
||
// ✅ Complete keyboard support
|
||
function Menu() {
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
switch(e.key) {
|
||
case 'ArrowDown':
|
||
focusNextItem();
|
||
break;
|
||
case 'ArrowUp':
|
||
focusPreviousItem();
|
||
break;
|
||
case 'Home':
|
||
focusFirstItem();
|
||
break;
|
||
case 'End':
|
||
focusLastItem();
|
||
break;
|
||
case 'Escape':
|
||
closeMenu();
|
||
break;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div role="menu" onKeyDown={handleKeyDown}>
|
||
{/* menu items */}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### 3. Dukungan Screen Reader
|
||
|
||
Pastikan semua konten dan interaksi diumumkan dengan benar ke screen reader menggunakan atribut ARIA saat diperlukan.
|
||
|
||
```tsx
|
||
// ✅ Proper ARIA labeling
|
||
<nav aria-label="Main navigation">
|
||
<ul>
|
||
<li><a href="/" aria-current="page">Home</a></li>
|
||
<li><a href="/about">About</a></li>
|
||
</ul>
|
||
</nav>
|
||
|
||
// ✅ Dynamic content announcements
|
||
<div aria-live="polite" aria-atomic="true">
|
||
{isLoading && <span>Loading results...</span>}
|
||
{results && <span>{results.length} results found</span>}
|
||
</div>
|
||
```
|
||
|
||
### 4. Aksesibilitas Visual
|
||
|
||
Dukung pengguna dengan gangguan penglihatan melalui kontras yang tepat, indikator fokus, dan ukuran teks responsif.
|
||
|
||
```css
|
||
/* ✅ Visible focus indicators */
|
||
button:focus-visible {
|
||
outline: 2px solid var(--color-focus);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
/* ✅ Sufficient color contrast (4.5:1 for normal text, 3:1 for large text) */
|
||
.text {
|
||
color: #333; /* Against white: 12.6:1 ratio */
|
||
background: white;
|
||
}
|
||
|
||
/* ✅ Responsive text sizing */
|
||
.text {
|
||
font-size: 1rem; /* Respects user preferences */
|
||
}
|
||
```
|
||
|
||
## Pola ARIA
|
||
|
||
### Memahami ARIA
|
||
|
||
ARIA (Accessible Rich Internet Applications) menyediakan informasi semantik tentang elemen kepada teknologi bantu. Gunakan ARIA untuk menambah, bukan menggantikan, HTML semantik.
|
||
|
||
Ada beberapa aturan yang harus Anda ikuti:
|
||
|
||
1. Jangan gunakan ARIA jika Anda bisa menggunakan HTML semantik
|
||
2. Jangan ubah semantik bawaan kecuali diperlukan
|
||
3. Semua elemen interaktif harus dapat diakses dengan keyboard
|
||
4. Jangan sembunyikan elemen yang bisa difokuskan dari teknologi bantu
|
||
5. Semua elemen interaktif harus memiliki nama yang dapat diakses
|
||
|
||
### Atribut ARIA Umum
|
||
|
||
#### Peran
|
||
|
||
Menentukan apa sebuah elemen itu:
|
||
|
||
```tsx
|
||
// Widget roles
|
||
<div role="button" tabIndex={0} onClick={handleClick}>
|
||
Custom Button
|
||
</div>
|
||
|
||
// Landmark roles
|
||
<div role="navigation" aria-label="Breadcrumb">
|
||
{/* breadcrumb items */}
|
||
</div>
|
||
|
||
// Live region roles
|
||
<div role="alert">
|
||
Error: Invalid email address
|
||
</div>
|
||
```
|
||
|
||
#### Status
|
||
|
||
Menjelaskan status saat ini dari sebuah elemen:
|
||
|
||
```tsx
|
||
// Checked state
|
||
<div
|
||
role="checkbox"
|
||
aria-checked={isChecked}
|
||
tabIndex={0}
|
||
>
|
||
Accept terms
|
||
</div>
|
||
|
||
// Expanded state
|
||
<button
|
||
aria-expanded={isOpen}
|
||
aria-controls="panel-1"
|
||
>
|
||
Toggle Panel
|
||
</button>
|
||
<div id="panel-1" hidden={!isOpen}>
|
||
Panel content
|
||
</div>
|
||
|
||
// Selected state
|
||
<li
|
||
role="option"
|
||
aria-selected={isSelected}
|
||
>
|
||
Option 1
|
||
</li>
|
||
```
|
||
|
||
#### Properti
|
||
|
||
Memberikan informasi tambahan:
|
||
|
||
```tsx
|
||
// Labels and descriptions
|
||
<input
|
||
aria-label="Search"
|
||
aria-describedby="search-help"
|
||
type="search"
|
||
/>
|
||
<span id="search-help">Press Enter to search</span>
|
||
|
||
// Relationships
|
||
<button aria-controls="modal-1">Open Modal</button>
|
||
<div id="modal-1" role="dialog">{/* modal content */}</div>
|
||
|
||
// Required and invalid
|
||
<input
|
||
aria-required="true"
|
||
aria-invalid={hasError}
|
||
aria-errormessage="email-error"
|
||
/>
|
||
<span id="email-error">Please enter a valid email</span>
|
||
```
|
||
|
||
## Pola Komponen
|
||
|
||
### Modal/Dialog
|
||
|
||
Modal membutuhkan manajemen fokus yang hati-hati dan penjebakan keyboard:
|
||
|
||
```tsx
|
||
function Modal({ isOpen, onClose, children }) {
|
||
const modalRef = useRef<HTMLDivElement>(null);
|
||
const previousFocus = useRef<HTMLElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
// Store current focus
|
||
previousFocus.current = document.activeElement as HTMLElement;
|
||
|
||
// Focus first focusable element in modal
|
||
const firstFocusable = modalRef.current?.querySelector<HTMLElement>(
|
||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||
);
|
||
firstFocusable?.focus();
|
||
|
||
// Prevent body scroll
|
||
document.body.style.overflow = 'hidden';
|
||
} else {
|
||
// Restore focus
|
||
previousFocus.current?.focus();
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
return () => {
|
||
document.body.style.overflow = '';
|
||
};
|
||
}, [isOpen]);
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Escape') {
|
||
onClose();
|
||
}
|
||
|
||
if (e.key === 'Tab') {
|
||
// Trap focus within modal
|
||
const focusables = modalRef.current?.querySelectorAll<HTMLElement>(
|
||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||
);
|
||
|
||
if (focusables && focusables.length > 0) {
|
||
const firstFocusable = focusables[0];
|
||
const lastFocusable = focusables[focusables.length - 1];
|
||
|
||
if (e.shiftKey && document.activeElement === firstFocusable) {
|
||
e.preventDefault();
|
||
lastFocusable.focus();
|
||
} else if (!e.shiftKey && document.activeElement === lastFocusable) {
|
||
e.preventDefault();
|
||
firstFocusable.focus();
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
if (!isOpen) return null;
|
||
|
||
return (
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="modal-title"
|
||
ref={modalRef}
|
||
onKeyDown={handleKeyDown}
|
||
className="modal"
|
||
>
|
||
<button
|
||
onClick={onClose}
|
||
aria-label="Close dialog"
|
||
className="close-button"
|
||
>
|
||
×
|
||
</button>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### Menu Dropdown
|
||
|
||
Dropdown memerlukan atribut ARIA yang tepat dan navigasi keyboard:
|
||
|
||
```tsx
|
||
function DropdownMenu({ items }) {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||
const menuRef = useRef<HTMLUListElement>(null);
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
switch(e.key) {
|
||
case 'ArrowDown':
|
||
e.preventDefault();
|
||
if (!isOpen) {
|
||
setIsOpen(true);
|
||
setSelectedIndex(0);
|
||
} else {
|
||
setSelectedIndex(prev =>
|
||
prev < items.length - 1 ? prev + 1 : 0
|
||
);
|
||
}
|
||
break;
|
||
|
||
case 'ArrowUp':
|
||
e.preventDefault();
|
||
if (isOpen) {
|
||
setSelectedIndex(prev =>
|
||
prev > 0 ? prev - 1 : items.length - 1
|
||
);
|
||
}
|
||
break;
|
||
|
||
case 'Enter':
|
||
case ' ':
|
||
e.preventDefault();
|
||
if (!isOpen) {
|
||
setIsOpen(true);
|
||
} else if (selectedIndex >= 0) {
|
||
items[selectedIndex].onClick();
|
||
setIsOpen(false);
|
||
}
|
||
break;
|
||
|
||
case 'Escape':
|
||
setIsOpen(false);
|
||
setSelectedIndex(-1);
|
||
break;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="dropdown">
|
||
<button
|
||
aria-haspopup="true"
|
||
aria-expanded={isOpen}
|
||
aria-controls="dropdown-menu"
|
||
onKeyDown={handleKeyDown}
|
||
onClick={() => setIsOpen(!isOpen)}
|
||
>
|
||
Menu
|
||
</button>
|
||
|
||
{isOpen && (
|
||
<ul
|
||
id="dropdown-menu"
|
||
role="menu"
|
||
ref={menuRef}
|
||
onKeyDown={handleKeyDown}
|
||
>
|
||
{items.map((item, index) => (
|
||
<li
|
||
key={item.id}
|
||
role="menuitem"
|
||
tabIndex={-1}
|
||
aria-selected={index === selectedIndex}
|
||
onClick={() => {
|
||
item.onClick();
|
||
setIsOpen(false);
|
||
}}
|
||
>
|
||
{item.label}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### Tabs
|
||
|
||
Antarmuka tab membutuhkan pola ARIA khusus dan navigasi keyboard:
|
||
|
||
```tsx
|
||
function Tabs({ tabs, defaultTab = 0 }) {
|
||
const [activeTab, setActiveTab] = useState(defaultTab);
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
|
||
let newIndex = index;
|
||
|
||
switch(e.key) {
|
||
case 'ArrowLeft':
|
||
newIndex = index > 0 ? index - 1 : tabs.length - 1;
|
||
break;
|
||
case 'ArrowRight':
|
||
newIndex = index < tabs.length - 1 ? index + 1 : 0;
|
||
break;
|
||
case 'Home':
|
||
newIndex = 0;
|
||
break;
|
||
case 'End':
|
||
newIndex = tabs.length - 1;
|
||
break;
|
||
default:
|
||
return;
|
||
}
|
||
|
||
e.preventDefault();
|
||
setActiveTab(newIndex);
|
||
|
||
// Focus the newly selected tab
|
||
const tabElement = document.getElementById(`tab-${newIndex}`);
|
||
tabElement?.focus();
|
||
};
|
||
|
||
return (
|
||
<div className="tabs">
|
||
<div role="tablist" aria-label="Tabs">
|
||
{tabs.map((tab, index) => (
|
||
<button
|
||
key={tab.id}
|
||
id={`tab-${index}`}
|
||
role="tab"
|
||
aria-selected={activeTab === index}
|
||
aria-controls={`panel-${index}`}
|
||
tabIndex={activeTab === index ? 0 : -1}
|
||
onClick={() => setActiveTab(index)}
|
||
onKeyDown={(e) => handleKeyDown(e, index)}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{tabs.map((tab, index) => (
|
||
<div
|
||
key={tab.id}
|
||
id={`panel-${index}`}
|
||
role="tabpanel"
|
||
aria-labelledby={`tab-${index}`}
|
||
hidden={activeTab !== index}
|
||
tabIndex={0}
|
||
>
|
||
{tab.content}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
### Formulir
|
||
|
||
Formulir membutuhkan label yang jelas, pesan kesalahan, dan umpan balik validasi:
|
||
|
||
```tsx
|
||
function AccessibleForm() {
|
||
const [errors, setErrors] = useState({});
|
||
|
||
return (
|
||
<form aria-label="Contact form">
|
||
<div className="form-group">
|
||
<label htmlFor="email">
|
||
Email Address
|
||
<span aria-label="required">*</span>
|
||
</label>
|
||
<input
|
||
id="email"
|
||
type="email"
|
||
aria-required="true"
|
||
aria-invalid={!!errors.email}
|
||
aria-describedby={errors.email ? "email-error" : "email-help"}
|
||
/>
|
||
<span id="email-help" className="help-text">
|
||
We'll never share your email
|
||
</span>
|
||
{errors.email && (
|
||
<span id="email-error" role="alert" className="error">
|
||
{errors.email}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<fieldset>
|
||
<legend>Notification Preferences</legend>
|
||
<div>
|
||
<input
|
||
id="notify-email"
|
||
type="checkbox"
|
||
name="notifications"
|
||
value="email"
|
||
/>
|
||
<label htmlFor="notify-email">Email notifications</label>
|
||
</div>
|
||
<div>
|
||
<input
|
||
id="notify-sms"
|
||
type="checkbox"
|
||
name="notifications"
|
||
value="sms"
|
||
/>
|
||
<label htmlFor="notify-sms">SMS notifications</label>
|
||
</div>
|
||
</fieldset>
|
||
|
||
<button type="submit">Submit</button>
|
||
</form>
|
||
);
|
||
}
|
||
```
|
||
|
||
## Manajemen Fokus
|
||
|
||
### Fokus Terlihat
|
||
|
||
Tampilkan indikator fokus hanya untuk navigasi keyboard:
|
||
|
||
```css
|
||
/* Remove default outline */
|
||
*:focus {
|
||
outline: none;
|
||
}
|
||
|
||
/* Show outline only for keyboard focus */
|
||
*:focus-visible {
|
||
outline: 2px solid var(--color-focus);
|
||
outline-offset: 2px;
|
||
}
|
||
|
||
/* Custom focus styles for specific components */
|
||
.button:focus-visible {
|
||
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
|
||
}
|
||
```
|
||
|
||
### Perangkap Fokus
|
||
|
||
Jaga fokus tetap berada dalam area tertentu:
|
||
|
||
```tsx
|
||
function useFocusTrap(ref: React.RefObject<HTMLElement>, isActive: boolean) {
|
||
useEffect(() => {
|
||
if (!isActive || !ref.current) return;
|
||
|
||
const element = ref.current;
|
||
const focusableSelector =
|
||
'a[href], button, textarea, input[type="text"], input[type="radio"], input[type="checkbox"], select, [tabindex]:not([tabindex="-1"])';
|
||
|
||
const focusableElements = element.querySelectorAll<HTMLElement>(focusableSelector);
|
||
const firstFocusable = focusableElements[0];
|
||
const lastFocusable = focusableElements[focusableElements.length - 1];
|
||
|
||
const handleTabKey = (e: KeyboardEvent) => {
|
||
if (e.key !== 'Tab') return;
|
||
|
||
if (e.shiftKey) {
|
||
if (document.activeElement === firstFocusable) {
|
||
e.preventDefault();
|
||
lastFocusable?.focus();
|
||
}
|
||
} else {
|
||
if (document.activeElement === lastFocusable) {
|
||
e.preventDefault();
|
||
firstFocusable?.focus();
|
||
}
|
||
}
|
||
};
|
||
|
||
element.addEventListener('keydown', handleTabKey);
|
||
firstFocusable?.focus();
|
||
|
||
return () => {
|
||
element.removeEventListener('keydown', handleTabKey);
|
||
};
|
||
}, [ref, isActive]);
|
||
}
|
||
```
|
||
|
||
### Pemulihan Fokus
|
||
|
||
Kembalikan fokus ke elemen yang sesuai setelah interaksi:
|
||
|
||
```tsx
|
||
function useRestoreFocus() {
|
||
const previousFocus = useRef<HTMLElement | null>(null);
|
||
|
||
const saveFocus = () => {
|
||
previousFocus.current = document.activeElement as HTMLElement;
|
||
};
|
||
|
||
const restoreFocus = () => {
|
||
previousFocus.current?.focus();
|
||
};
|
||
|
||
return { saveFocus, restoreFocus };
|
||
}
|
||
```
|
||
|
||
## Wilayah Dinamis
|
||
|
||
Umumkan perubahan konten dinamis ke screen reader:
|
||
|
||
### Pesan Status
|
||
|
||
```tsx
|
||
// Polite announcement (waits for screen reader to finish)
|
||
<div role="status" aria-live="polite">
|
||
{savedMessage && "Settings saved successfully"}
|
||
</div>
|
||
|
||
// Assertive announcement (interrupts screen reader)
|
||
<div role="alert" aria-live="assertive">
|
||
{errorMessage && `Error: ${errorMessage}`}
|
||
</div>
|
||
|
||
// Loading states
|
||
<div aria-live="polite" aria-busy={isLoading}>
|
||
{isLoading ? "Loading..." : `${items.length} items loaded`}
|
||
</div>
|
||
```
|
||
|
||
### Indikator Kemajuan
|
||
|
||
```tsx
|
||
function ProgressBar({ value, max = 100 }) {
|
||
return (
|
||
<div
|
||
role="progressbar"
|
||
aria-valuenow={value}
|
||
aria-valuemin={0}
|
||
aria-valuemax={max}
|
||
aria-label="Upload progress"
|
||
>
|
||
<div
|
||
className="progress-fill"
|
||
style={{ width: `${(value / max) * 100}%` }}
|
||
/>
|
||
<span className="sr-only">
|
||
{Math.round((value / max) * 100)}% complete
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
## Warna dan Kontras
|
||
|
||
### Persyaratan Kontras
|
||
|
||
Ikuti pedoman WCAG untuk kontras warna:
|
||
|
||
```css
|
||
/* Normal text (< 18pt or < 14pt bold) */
|
||
.text {
|
||
color: #595959; /* 7:1 ratio against white */
|
||
background: white;
|
||
}
|
||
|
||
/* Large text (≥ 18pt or ≥ 14pt bold) */
|
||
.heading {
|
||
color: #767676; /* 4.5:1 ratio against white */
|
||
font-size: 1.5rem;
|
||
font-weight: bold;
|
||
}
|
||
|
||
/* Non-text elements (icons, borders) */
|
||
.icon {
|
||
color: #949494; /* 3:1 ratio against white */
|
||
}
|
||
```
|
||
|
||
### Tidak Bergantung pada Warna
|
||
|
||
Jangan pernah menyampaikan informasi hanya melalui warna:
|
||
|
||
```tsx
|
||
// ❌ Color only
|
||
<span className="text-red-500">Error</span>
|
||
|
||
// ✅ Color with text/icon
|
||
<span className="text-red-500">
|
||
<ErrorIcon aria-hidden="true" />
|
||
<span>Error: Invalid input</span>
|
||
</span>
|
||
|
||
// ✅ Multiple indicators
|
||
<input
|
||
className={hasError ? 'border-red-500' : 'border-gray-300'}
|
||
aria-invalid={hasError}
|
||
aria-describedby={hasError ? 'error-message' : undefined}
|
||
/>
|
||
{hasError && (
|
||
<span id="error-message" className="text-red-500">
|
||
<ErrorIcon aria-hidden="true" /> This field is required
|
||
</span>
|
||
)}
|
||
```
|
||
|
||
## Aksesibilitas Seluler
|
||
|
||
### Sasaran Sentuh
|
||
|
||
Pastikan area sentuh cukup besar:
|
||
|
||
```css
|
||
/* Minimum 44x44px for iOS, 48x48dp for Android */
|
||
.button {
|
||
min-height: 44px;
|
||
min-width: 44px;
|
||
padding: 12px 16px;
|
||
}
|
||
|
||
/* Add invisible touch area for small icons */
|
||
.icon-button {
|
||
position: relative;
|
||
padding: 8px;
|
||
}
|
||
|
||
.icon-button::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: -8px;
|
||
right: -8px;
|
||
bottom: -8px;
|
||
left: -8px;
|
||
}
|
||
```
|
||
|
||
### Viewport dan Zoom
|
||
|
||
Izinkan pengguna untuk melakukan zoom:
|
||
|
||
```html
|
||
<!-- ✅ Allows zooming -->
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
|
||
<!-- ❌ Prevents zooming -->
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||
```
|
||
|
||
## Kesalahan Umum
|
||
|
||
### Teks Placeholder sebagai Label
|
||
|
||
Jangan gunakan placeholder sebagai satu-satunya label:
|
||
|
||
```tsx
|
||
// ❌ Placeholder disappears when typing
|
||
<input placeholder="Email address" />
|
||
|
||
// ✅ Persistent label
|
||
<label>
|
||
Email address
|
||
<input type="email" />
|
||
</label>
|
||
|
||
// ✅ Floating label pattern
|
||
<div className="form-field">
|
||
<input id="email" placeholder=" " />
|
||
<label htmlFor="email">Email address</label>
|
||
</div>
|
||
```
|
||
|
||
### Tombol Tanpa Teks
|
||
|
||
Selalu sediakan teks yang dapat diakses untuk tombol ikon:
|
||
|
||
```tsx
|
||
// ❌ No accessible name
|
||
<button onClick={handleDelete}>
|
||
<TrashIcon />
|
||
</button>
|
||
|
||
// ✅ Screen reader text
|
||
<button onClick={handleDelete} aria-label="Delete item">
|
||
<TrashIcon aria-hidden="true" />
|
||
</button>
|
||
|
||
// ✅ Visually hidden text
|
||
<button onClick={handleDelete}>
|
||
<TrashIcon aria-hidden="true" />
|
||
<span className="sr-only">Delete item</span>
|
||
</button>
|
||
```
|
||
|
||
### Elemen Formulir yang Dinonaktifkan
|
||
|
||
Elemen yang dinonaktifkan tidak dapat difokuskan, yang dapat membingungkan pengguna:
|
||
|
||
```tsx
|
||
// ❌ User can't understand why button is disabled
|
||
<button disabled={!isValid}>
|
||
Submit
|
||
</button>
|
||
|
||
// ✅ Use aria-disabled and explain
|
||
<button
|
||
aria-disabled={!isValid}
|
||
aria-describedby="submit-help"
|
||
onClick={isValid ? handleSubmit : undefined}
|
||
className={!isValid ? 'opacity-50 cursor-not-allowed' : ''}
|
||
>
|
||
Submit
|
||
</button>
|
||
<span id="submit-help">
|
||
{!isValid && 'Please fill in all required fields'}
|
||
</span>
|
||
``` |