Building Accessible Components — UI/UX

Membangun Komponen Accessible Component library yang accessible dari awal menghemat waktu jauh lebih banyak daripada fix di akhir. Prinsipnya: semantic HTML…

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 menambah role="radio" dan aria-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:

  1. HTML semantik dulu. Kalau ada elemen asli dengan role dan perilaku yang kamu butuhkan (<button>, <input type="radio">, <dialog>, <details>), pakai itu.
  2. 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.
  3. 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>

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:

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:

📌 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

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.

Yang akan kamu pelajari