Komponen yang accessible adalah komponen yang bisa dipakai siapa pun: dengan mouse, dengan keyboard saja, dan dengan screen reader, dan yang memberi tahu screen reader role (apa dia), nama (namanya apa), dan state (keadaannya sekarang). Bayangin saklar lampu di kamar Bu Sri. Saklar standar langsung dipahami semua tamu: tekan, lampu nyala. Panel sentuh buatan sendiri mungkin lebih cantik, tapi Bu Sri harus menjelaskannya ke tiap tamu, dan tamu yang gak bisa melihat gak tahu panel itu ada. Elemen HTML asli itu saklar standar; ARIA dan JavaScript itu panel buatan sendiri, yang semua penjelasannya harus kamu tulis.
🧪 Tebakan cepat: Tim Bu Sri membuat pilihan 1, 2, 3 malam dari tiga
<div>, lalu menambahrole="radio"danaria-checked. Pak Hadi menekan Tab dari tombol di atasnya. Fokusnya berhenti di pilihan malam? Jawabannya ada di bagian "Pilihan malam: mulai dari radio asli".
Tiga prinsip
Membangun accessible dari awal jauh lebih murah daripada memperbaiki di akhir, setelah komponennya dipakai di puluhan halaman. Prinsipnya tiga, urut:
- HTML semantik dulu. Kalau ada elemen asli dengan role dan perilaku yang kamu butuhkan (
<button>,<input type="radio">,<dialog>,<details>), pakai itu. - ARIA cuma kalau perlu. ARIA mengubah pohon aksesibilitas, bukan perilaku (pelajaran ARIA Roles & States Deep Dive). Pakai untuk hal yang gak punya elemen HTML, seperti tab.
- Keyboard selalu. Tiap komponen harus bisa dipakai dengan Tab, Enter, Spasi, Escape, dan tombol panah, persis seperti yang ditulis ARIA Authoring Practices Guide (APG) untuk polanya.
Lima komponen halaman pesan Homestay Kabut, yang sama dengan Lab di halaman ini. Kolom tengah dibaca dari pohon aksesibilitas Chromium 147:
| Komponen | Mulai dari | Role, nama, state | Pola APG |
|---|---|---|---|
| Pilihan malam | <fieldset> + <input type="radio"> |
group "Lama menginap", radio "2 malam" checked=true |
Radio Group |
| Foto kamar | ARIA: tablist, tab, tabpanel |
tablist "Foto Kamar Teras", tab "Kamar" selected=true |
Tabs |
| Konfirmasi pesanan | <dialog> + showModal() |
dialog "Pesan Kamar Teras?" modal=true |
Dialog (Modal) |
| Kabar "Pesanan terkirim" | role="status" |
status live=polite atomic=true |
(live region, WCAG 4.1.3) |
| Kebijakan batal | <details> + <summary> |
DisclosureTriangle "Kebijakan batal" expanded=false |
Disclosure (Show/Hide) |
Pilihan malam: mulai dari radio asli
<fieldset class="nights">
<legend>Lama menginap</legend>
<label><input type="radio" name="malam" value="1"> 1 malam</label>
<label><input type="radio" name="malam" value="2" checked> 2 malam</label>
<label><input type="radio" name="malam" value="3"> 3 malam</label>
</fieldset>
<legend>memberi nama grupnya,<label>memberi nama tiap radio, dancheckedjadi statechecked=true.nameyang sama menjadikan ketiganya satu grup: satu Tab stop, dan tombol panah memilih.
Keyboard menurut APG (Radio Group Pattern): Tab masuk ke radio yang terpilih; panah kanan atau bawah pindah ke radio berikutnya sekaligus memilihnya; panah kiri atau atas mundur; Spasi memilih radio yang difokus. Browser mengerjakan semuanya. APG menulis panah dari radio terakhir kembali ke yang pertama: Chromium dan Firefox melakukannya, WebKit 26.4 berhenti di 3 malam.
Versi <div role="radio" aria-checked="true"> memberi pohon yang mirip (radio "2 malam" checked=true), tapi tanpa focusable. Di Chromium, WebKit, dan Firefox, Tab dari tombol di atasnya langsung melompat ke tombol Pesan. Jadi jawaban tebakannya: gak. Role cuma janji; supaya div itu bekerja, kamu masih harus menulis tabindex, roving tabindex, panah, dan Spasi sendiri. Radio asli memberi semua itu gratis. Itu jawaban tebakannya.
Foto kamar: tab, karena HTML gak punya elemen tab
Untuk tab foto (Kamar, Kamar mandi, Teras) gak ada elemen asli, jadi ini tempat ARIA. Markup lengkapnya ada di pelajaran ARIA Roles & States Deep Dive dan script panahnya di Keyboard Navigation Patterns; ringkasnya:
- Peran:
role="tablist"denganaria-label="Foto Kamar Teras", tiap tab<button role="tab">denganaria-selecteddanaria-controls, tiap panelrole="tabpanel"denganaria-labelledbydantabindex="0". - Keyboard (APG Tabs Pattern): Tab masuk ke tab yang aktif, Tab berikutnya ke panelnya; panah kanan dan kiri pindah tab dan langsung menampilkan fotonya, berputar di ujung; Home dan End ke tab pertama dan terakhir.
- Roving tabindex: tab aktif
tabindex="0", sisanya-1. Script-mu yang memperbaruiaria-selected,tabindex, danhiddensetiap kali tab berganti.
Konfirmasi: dialog dengan showModal()
<button class="book" id="pesan">Pesan kamar, bayar di tempat</button>
<dialog id="konfirmasi" aria-labelledby="konfirmasi-judul">
<form method="dialog">
<h2 id="konfirmasi-judul">Pesan Kamar Teras?</h2>
<p id="ringkas">2 malam · Rp 700.000, bayar di tempat</p>
<button value="batal">Batal</button>
<button value="pesan">Ya, pesan</button>
</form>
</dialog>
Keyboard menurut APG (Dialog (Modal) Pattern): saat terbuka, fokus pindah ke dalam dialog; Tab dan Shift+Tab tetap di dalam; Escape menutup; setelah tertutup, fokus kembali ke tombol yang membukanya. showModal() mengerjakan keempatnya (pelajaran Modals & Overlays). Di uji kami, Enter di tombol Pesan memindahkan fokus ke Batal, Escape menutup dengan returnValue kosong, dan fokus kembali ke "Pesan kamar, bayar di tempat".
Kalau tim membuat modal sendiri di React, bagian terakhir itu paling sering terlewat: fokus jatuh ke awal halaman, dan Pak Hadi harus menekan Tab dari atas lagi. Simpan elemen yang fokus sebelum modal dibuka, lalu fokuskan lagi saat ditutup:
useEffect(() => {
if (!open) return;
const sebelum = document.activeElement; // tombol Pesan
modalRef.current.focus();
return () => sebelum?.focus(); // jalan saat modal ditutup
}, [open]);
aria-modal="true" gak melakukan ini: dia cuma memberi tahu screen reader bahwa halaman di belakang sedang gak aktif.
Kabar "Pesanan terkirim": live region
<div class="toast" role="status"></div> <!-- ada sejak halaman dimuat, kosong -->
<script>
function kabari() {
document.querySelector('.toast').textContent = 'Pesanan terkirim. Bu Sri akan konfirmasi lewat WhatsApp.';
}
</script>
Toast gak pernah difokus dan gak punya tombol keyboard. Yang penting role-nya: role="status" berarti aria-live="polite" dan aria-atomic="true", jadi kalimatnya dibacakan setelah screen reader selesai bicara, tanpa fokus pindah (WCAG 4.1.3 Status Messages). Region-nya harus sudah ada sebelum teksnya diisi. Toast dari <div> biasa tampil sama persis, tapi diam untuk screen reader.
Hal yang sama berlaku untuk tombol yang cuma berupa ikon. Saat mengirim, tombol Pesan menampilkan ikon jam pasir:
<button class="book" aria-disabled="true">
<svg aria-hidden="true" viewBox="0 0 24 24">...</svg>
<span class="sr-only">Memproses...</span>
</button>
aria-hidden="true" mengeluarkan ikon hiasan dari pohon, dan .sr-only (kotak 1px yang dipotong dengan clip) menyembunyikan teks dari mata, bukan dari screen reader. Chromium memberi button "Memproses..." disabled=true. Tanpa span itu, namanya kosong: button "".
Kebijakan batal: disclosure dengan details
<details class="policy">
<summary>Kebijakan batal</summary>
<p>Gratis batal sampai 3 hari sebelum check-in. Batal lewat dari itu? Kabari Bu Sri lewat WhatsApp.</p>
</details>
Keyboard menurut APG (Disclosure (Show/Hide) Pattern): Enter atau Spasi membuka dan menutup. <summary> sudah bisa difokus, dan browser sendiri yang memperbarui state expanded. Versi ARIA-nya, <button aria-expanded="false" aria-controls="...">, juga benar, tapi state-nya harus diperbarui script-mu.
Cek di tiga browser
Kami menjalankan halaman Lab ini, versi div dan versi accessible, di Chromium 147, WebKit 26.4 (Tab dengan Option, seperti di pelajaran Keyboard Navigation Patterns), dan Firefox 146:
| Versi div | Versi accessible | |
|---|---|---|
| Tab berhenti di | 1 tempat: tombol Pesan | 5 tempat: tab aktif, panel, radio terpilih, Pesan, Kebijakan batal |
| Panah di tab foto dan pilihan malam | gak terjadi apa-apa | pindah dan memilih |
| Escape di konfirmasi | kotak tetap terbuka | tertutup, fokus kembali ke Pesan |
| Enter dan Spasi di kebijakan | gak bisa difokus | membuka dan menutup |
| axe-core 4.14 | 0 violations | 0 violations |
Baris terakhir penting: axe gak menemukan apa pun di versi div. Pelajaran Automated a11y Testing menjelaskan kenapa: alat otomatis gak menguji perilaku keyboard.
Checklist per komponen
Sebelum sebuah komponen masuk component library:
- Role, nama, state benar di tab Accessibility DevTools, dan state-nya berubah saat dipakai.
- Keyboard sesuai pola APG-nya: Tab, Enter, Spasi, Escape, panah.
- Fokus terlihat (WCAG 2.4.7) dan gak tertutup elemen yang menempel (2.4.11).
- Kontras teks minimal 4,5:1 (teks besar 3:1), dan garis komponen serta cincin fokus minimal 3:1 (1.4.11).
- Zoom 200% dan lebar 320px tetap bisa dipakai (1.4.4, 1.4.10).
- Screen reader mengumumkan perubahan state dan kabar (pelajaran Screen Reader Testing).
📌 Library yang membantu
Kalau kamu gak mau menulis pola APG sendiri, library unstyled seperti Radix UI, React Aria (Adobe), dan Headless UI (tim Tailwind) sudah mengerjakan role, state, dan keyboard-nya. Tetap jalankan checklist di atas: library cuma membantu kalau dipakai sesuai dokumentasinya, misalnya tetap memberi nama di dialog dan tombol ikon.
⚠️ Jebakan yang sering ketemu
<div onClick>lalu ARIA belakangan. Role menambah janji di pohon, tapi fokus, panah, dan Spasi tetap gak ada.- State yang gak ikut berubah.
aria-selectedatauaria-expandedyang lupa diperbarui membuat screen reader melaporkan keadaan yang salah. - Modal buatan sendiri yang lupa mengembalikan fokus. Simpan
document.activeElementsebelum membuka, fokuskan lagi saat menutup, atau pakai<dialog>. - Live region yang dibuat bersamaan dengan teksnya. Region harus sudah ada di halaman sebelum teksnya diisi.
- Ikon tanpa nama. Sembunyikan ikonnya dengan
aria-hidden="true", dan beri teks.sr-onlyatauaria-labeldi tombolnya.
TL;DR: Tiga prinsip, urut: HTML semantik dulu, ARIA cuma kalau perlu, keyboard selalu. Tiap komponen harus punya role, nama, dan state yang benar di pohon aksesibilitas dan keyboard sesuai pola APG. Pilihan malam: <fieldset>, <legend>, <input type="radio"> (Radio Group: Tab ke yang terpilih, panah pindah sekaligus memilih); <div role="radio"> gak bisa di-Tab. Foto kamar: tablist, tab dengan aria-selected, tabpanel, roving tabindex, panah dan Home/End (Tabs). Konfirmasi: <dialog> + showModal() dengan aria-labelledby (Dialog (Modal): fokus masuk, Tab di dalam, Escape menutup, fokus kembali); modal buatan sendiri harus menyimpan document.activeElement dan memfokuskannya lagi saat ditutup. Kabar: role="status" yang sudah ada sejak awal (polite, atomic, WCAG 4.1.3). Ikon di tombol: aria-hidden="true" plus teks .sr-only, jadi namanya "Memproses...". Kebijakan batal: <details>/<summary> (Disclosure: Enter dan Spasi). Diuji di Chromium, WebKit, dan Firefox; axe memberi 0 violations untuk versi div maupun accessible, jadi keyboard tetap harus dicoba sendiri. Checklist: role, nama, state; keyboard; fokus terlihat; kontras 4,5:1 dan 3:1; zoom 200% dan 320px; screen reader.