jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

Web Accessibility Deep Dive — ARIA, the Accessibility Tree, and Complex Widgets

A senior guide to the accessibility tree, ARIA authoring patterns, keyboard semantics, live regions, and pragmatic a11y testing for design systems.

Tại sao Accessibility là kỹ thuật, không phải checkbox

Accessibility không phải lớp hoàn thiện gắn thêm trước khi ship. Đó là hợp đồng giữa UI và assistive technologies (screen reader, thiết bị switch, điều khiển giọng nói, chế độ tương phản cao) thông qua platform accessibility APIs.

Ba lực hội tụ với team senior:

ForceWhy it matters
LegalPháp lý: ADA, Section 508, EN 301 549 và chỉ thị EU tạo rủi ro pháp lý thật cho sản phẩm công khai.
EthicalĐạo đức: ~15% dân số thế giới có khuyết tật; loại trừ họ là lỗi sản phẩm, không phải edge case nhỏ.
Engineering qualityChất lượng kỹ thuật: HTML ngữ nghĩa, luồng bàn phím dự đoán được và focus rõ ràng cải thiện trải nghiệm mọi người — power user, mobile, mạng chậm, automated testing.

Bài học principal: Coi a11y như performance hay security — đặt ngân sách, tự động hoá regression check, và thiết kế component với semantics accessibility từ ngày đầu.


WCAG 2.2 — POUR và mức tuân thủ

WCAG 2.2 tổ chức yêu cầu quanh bốn nguyên tắc:

PrincipleMeaningExample success criterion
PerceivableNgười dùng nhận thức được nội dung bất kể giác quan1.4.3 Contrast (Minimum) — 4.5:1 for normal text
OperableUI dùng được bằng bàn phím và input hỗ trợ2.1.1 Keyboard — all functionality available from keyboard
UnderstandableNội dung và hành vi dự đoán được3.3.1 Error Identification — errors described in text
RobustHoạt động trên nhiều browser và AT4.1.2 Name, Role, Value — programmatically determinable

Mức tuân thủ xếp chồng:

LevelScopeTypical target
AMức tối thiểu; lỗi chặn truy cập cơ bảnRarely sufficient alone for enterprise products
AAChuẩn ngành cho hầu hết quy định và mua sắmDefault target for design systems and SaaS
AAANâng cao; thường không khả thi cho toàn bộ sản phẩmCherry-pick specific criteria (e.g. 1.4.6 enhanced contrast)

Tiêu chí WCAG cần nhưng chưa đủ. Pass automated check không đảm bảo trải nghiệm screen reader tốt — vẫn cần test bàn phím và AT thủ công.


Accessibility Tree — Assistive tech thực sự thấy gì

Browser không expose DOM thô cho screen reader. Chúng dựng cấu trúc song song: accessibility tree (còn gọi AX tree hoặc platform accessibility tree).

DOM                          Accessibility tree (simplified)
─────────────────────────────────────────────────────────────
<div class="card">           ignored (no semantic role)
  <h2>Settings</h2>    →     heading, level=2, name="Settings"
  <button>Save</button> →    button, name="Save", focusable
  <span aria-hidden>   →     pruned from tree
    decorative icon
  </span>
</div>

Mỗi node accessible mang:

PropertyDescription
RoleWhat it is (button, link, heading, dialog, tab) — from implicit HTML semantics or explicit ARIA
NameWhat it is called — computed from visible text, aria-label, aria-labelledby, or alt
StatesDynamic booleans — checked, expanded, selected, disabled, pressed
PropertiesAdditional metadata — aria-controls, aria-describedby, aria-haspopup

Tính toán Accessible Name

Accessible name là chuỗi screen reader đọc khi focus vào element. Ưu tiên (rút gọn) theo spec Accessible Name and Description Computation:

  1. tham chiếu một hoặc nhiều element, nối text
  2. chuỗi do tác giả cung cấp
  3. liên kết label native hoặc text content của element
  4. chỉ fallback, không thay label hiển thị
<!-- ✅ Preferred: visible label + programmatic association -->
<label for="email">Email address</label>
<input id="email" type="email" autocomplete="email" />

<!-- ✅ When visual design hides the label, still expose a name -->
<label for="search" class="sr-only">Search products</label>
<input id="search" type="search" placeholder="Search…" />

<!-- ⚠️ aria-label overrides visible text — use when intentional -->
<button aria-label="Close dialog">×</button>

<!-- ❌ Icon-only button with no accessible name -->
<button><svg aria-hidden="true">…</svg></button>

Tại sao quan trọng với principal: Design system thường ship primitive “headless” mà nguồn name mơ hồ. Ghi rõ prop nào thắng (label, aria-label, children) và enforce trong component API.

Role: Ngầm vs Tường minh

HTML native mang implicit role<button>button, <a href>link, v.v..

Thêm role="button" trên <div> không cho hành vi button — chỉ thông báo. Phải tự implement kích hoạt bàn phím, focusability và disabled.


HTML ngữ nghĩa trước — Năm quy tắc ARIA

Spec WAI-ARIA mở đầu bằng năm quy tắc:

  1. Đừng dùng ARIA nếu đã có element HTML native.
  2. Đừng đổi semantics native trừ khi thực sự cần.
  3. Mọi control ARIA tương tác phải dùng được bằng bàn phím.
  4. Đừng dùng role="presentation" hay aria-hidden="true" trên element focusable.
  5. Mọi element tương tác phải có accessible name.

Khi nào element native thắng

NeedPreferAvoid
Toggle<input type="checkbox"><div role="checkbox">
Expand/collapse<details> / <summary>Accordion without keyboard
Modal<dialog><div role="dialog"> without trap
Site nav<nav> + linksrole="menu" for primary nav

Lỗi phổ biến: Dùng role="menu"role="menuitem" cho navigation site chính. Trong ARIA, menu là widget ứng dụng (context menu, menubar), không phải danh sách link. Nav site nên là <nav> với danh sách link.


Accessibility bàn phím — Focus là con trỏ của người dùng AT

Nếu không reach và operate được bằng bàn phím thì không accessible. Người dùng bàn phím gồm người khuyết tật vận động, power user, và ai đó thiết bị trỏ hỏng.

Thứ tự focus và tabindex

Thứ tự Tab theo thứ tự DOM giữa element focusable. Chỉ những cái này tham gia sequential focus navigation mặc định: <a href>, <button>, <input>, <select>, <textarea>,

tabindex valueBehavior
(absent)Quy tắc focusability native
0Đưa element vào tab order tự nhiên tại vị trí DOM
-1Focus được bằng code nhưng bỏ qua trong chuỗi tab
positive (1, 2, …)Antipattern — tạo tab order tùy chỉnh chống lại DOM order và gây rối cho người dùng
<!-- ✅ Roving tabindex pattern: one tab stop, arrow keys inside -->
<div role="tablist">
  <button role="tab" tabindex="0" aria-selected="true">General</button>
  <button role="tab" tabindex="-1" aria-selected="false">Security</button>
</div>

:focus-visible — Hiện focus khi cần

:focus kích hoạt với mọi focus, kể cả click chuột. :focus-visible khớp khi browser quyết focus nên hiển thị rõ (thường là bàn phím).

/* Remove default outline only if you replace it */
:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}

/* ❌ Never do this globally */
/* *:focus { outline: none; } */

Skip link là element focusable đầu tiên trên trang, nhảy qua chrome lặp tới #main.

<a href="#main" class="skip-link">Skip to main content</a>
<!-- … header, nav … -->
<main id="main" tabindex="-1">…</main>

Đặt tabindex="-1" trên <main> cho phép focus programmatic sau skip để screen reader vào main content.

Quản lý focus trong Dialog

Khi modal mở:

  1. Chuyển focus tới element focusable đầu tiên.
  2. Giữ focus trong dialog đến khi đóng.
  3. Khi đóng, khôi phục focus về element mở dialog.
  4. Escape đóng (trừ hành động phá huỷ cần xác nhận).

Element <dialog> native với .showModal() cung cấp top layer, backdrop inert và hành vi Escape trên browser hỗ trợ. Vẫn cần nối focus restore và verify trap behavior trên nhiều browser.


Widget phức tạp — WAI-ARIA Authoring Practices

ARIA Authoring Practices Guide (APG) mô tả tương tác bàn phím, rolequản lý state cho widget composite. Dưới đây là pattern tối thiểu production-grade.

<button type="button" id="open-settings">Settings</button>

<dialog id="settings-dialog" aria-labelledby="settings-title">
  <header>
    <h2 id="settings-title">Settings</h2>
    <button type="button" aria-label="Close" data-close>×</button>
  </header>
  <form method="dialog">
    <label for="theme">Theme</label>
    <select id="theme" name="theme">
      <option value="dark">Dark</option>
      <option value="light">Light</option>
    </select>
    <menu>
      <button type="submit" value="save">Save</button>
      <button type="button" data-close>Cancel</button>
    </menu>
  </form>
</dialog>
const dialog = document.getElementById('settings-dialog');
const trigger = document.getElementById('open-settings');
let previousFocus = null;

trigger.addEventListener('click', () => {
  previousFocus = document.activeElement;
  dialog.showModal();
});

dialog.addEventListener('close', () => previousFocus?.focus());
dialog.querySelectorAll('[data-close]').forEach((btn) => {
  btn.addEventListener('click', () => dialog.close());
});
// Add Tab-wrap focus trap if target browsers lack native dialog focus management

Tại sao <dialog>: Map tới role="dialog", tham gia top layer, chặn tương tác với trang phía sau. Screen reader thường announce dialog và có thể đổi reading mode.

Tabs — Roving tabindex

Pattern tabs APG dùng:

  • role="tablist" container with aria-orientation="horizontal" (or vertical)
  • Each tab: role="tab", aria-selected, aria-controls pointing to panel id
  • Each panel: role="tabpanel", tabindex="0" (optional — allows panel to receive focus), aria-labelledby the tab

Bàn phím: Arrow di chuyển giữa tab; Home/End nhảy đầu/cuối; Tab thoát tablist vào panel active.

<div>
  <div role="tablist" aria-label="Account settings">
    <button role="tab" id="tab-general" aria-selected="true" aria-controls="panel-general" tabindex="0">
      General
    </button>
    <button role="tab" id="tab-security" aria-selected="false" aria-controls="panel-security" tabindex="-1">
      Security
    </button>
  </div>
  <div role="tabpanel" id="panel-general" aria-labelledby="tab-general" tabindex="0">
    General settings content
  </div>
  <div role="tabpanel" id="panel-security" aria-labelledby="tab-security" hidden tabindex="0">
    Security settings content
  </div>
</div>
function activateTab(tab, tabs) {
  tabs.forEach((t) => {
    const selected = t === tab;
    t.setAttribute('aria-selected', String(selected));
    t.tabIndex = selected ? 0 : -1;
    document.getElementById(t.getAttribute('aria-controls')).hidden = !selected;
  });
  tab.focus();
}
// Wire ArrowLeft/ArrowRight/Home/End on tablist; click handlers call activateTab

Trade-off: Roving tabindex nghĩa chỉ một tab trong tab sequence — giảm tab stop cho user bàn phím nhưng cần arrow key dễ khám phá. Ghi arrow-key behavior trong docs design system.

Combobox / Autocomplete

Combobox kết hợp input text với popup listbox. Semantics quan trọng:

  • Input: role="combobox", aria-expanded, aria-controls (listbox id), aria-autocomplete="list" (or both)
  • Listbox: role="listbox", options as role="option" with aria-selected
  • aria-activedescendant trên input trỏ tới id option highlighted trong khi focus ở input
<label for="country-input">Country</label>
<div class="combobox">
  <input
    id="country-input"
    type="text"
    role="combobox"
    aria-expanded="false"
    aria-controls="country-listbox"
    aria-autocomplete="list"
    aria-activedescendant=""
    autocomplete="off"
  />
  <ul id="country-listbox" role="listbox" hidden>
    <li id="opt-vn" role="option" aria-selected="false">Vietnam</li>
    <li id="opt-us" role="option" aria-selected="false">United States</li>
  </ul>
</div>

Hành vi screen reader: Với aria-activedescendant, NVDA và VoiceOver announce option active khi arrow list mà không di chuyển DOM focus. Tránh mất focus trong input.

Bàn phím (APG): Down/Up mở list và di chuyển option; Enter chọn; Escape đóng; gõ lọc.

Ghi chú principal: Combobox là widget lỗi nhiều nhất — chỉ ship khi có đủ keyboard APG hoặc hoãn.

Disclosure / Accordion

Ưu tiên <details> native khi một section toggle độc lập:

<details>
  <summary>Shipping policy</summary>
  <p>Free shipping on orders over $50.</p>
</details>

Cho accordion (một panel mở, danh sách header chung), dùng <button> trong heading với aria-expandedaria-controls:

<h3>
  <button type="button" aria-expanded="false" aria-controls="faq-1" id="faq-1-btn">
    What is your return policy?
  </button>
</h3>
<div id="faq-1" role="region" aria-labelledby="faq-1-btn" hidden>
  Returns accepted within 30 days.
</div>

Tại sao <button> trong <h3>: Giữ heading level cho outline document đồng thời expose button semantics và kích hoạt bàn phím.


Live region — Thông báo cập nhật động

SPA và UI async mutate DOM không reload trang. User nhìn thấy spinner và toast; user screen reader không nghe gì nếu không dùng live region.

MechanismPolitenessUse case
aria-live="polite"Chờ pause trong speechStatus updates, form save confirmation
aria-live="assertive"Ngắt ngayCritical errors (use sparingly)
role="status"Implicit aria-live="polite"Loading complete, item added to cart
role="alert"Implicit aria-live="assertive"Validation errors affecting submission
<div role="status" aria-live="polite" aria-atomic="true" class="sr-only" id="route-announcer"></div>
function announce(message) {
  const region = document.getElementById('route-announcer');
  region.textContent = '';
  // Clearing then setting forces re-announcement in most AT
  requestAnimationFrame(() => {
    region.textContent = message;
  });
}

// After client-side route change
announce('Navigated to Dashboard');

Đổi route SPA: Chuyển focus tới <main> (hoặc heading riêng) announce title trang mới. Mô phỏng full navigation.

Pitfall: Nhiều region assertive đồng thời gây thông báo hỗn loạn. Queue hoặc dedupe announcement trong app shell.


Form — Label, lỗi và trường bắt buộc

Label không thể thương lượng

Mọi input cần label hiển thị hoặc programmatic. Placeholder không phải label — Biến mất khi nhập và thường fail contrast.

<label for="password">Password</label>
<input id="password" type="password" autocomplete="current-password" required aria-required="true" />

Thông báo lỗi

Liên kết lỗi với input bằng aria-describedby (nhiều id) và đánh dấu invalid bằng aria-invalid="true":

<label for="email">Email</label>
<input
  id="email"
  type="email"
  aria-describedby="email-hint email-error"
  aria-invalid="true"
/>
<p id="email-hint">We will never share your email.</p>
<p id="email-error" role="alert">Enter a valid email address.</p>

Luồng screen reader: Focus input → name “Email” → description gồm hint và lỗi nếu có. Chuyển focus tới lỗi thay vì liên kết buộc user tìm kiếm — Dùng role="alert" trên summary khi submit fail, nhưng giữ liên kết cấp field.

Nhóm với fieldset / legend

Nhóm radio và checkbox cần legend cho tên nhóm:

<fieldset>
  <legend>Notification preferences</legend>
  <label><input type="checkbox" name="notify" value="email" /> Email</label>
  <label><input type="checkbox" name="notify" value="sms" /> SMS</label>
</fieldset>

Yếu tố thị giác và vận động

Tương phản màu

WCAG 2.2 level AA yêu cầu:

ContentMinimum contrast ratio
Normal text (< 18pt / < 14pt bold)4.5:1
Large text (≥ 18pt / ≥ 14pt bold)3:1
UI components and graphical objects3:1 against adjacent colors

Đừng chỉ dựa màu để truyền state (thêm icon, text hoặc pattern). Aesthetic terminal với accent lime trên nền tối — verify token bằng công cụ contrast hoặc axe.

prefers-reduced-motion

Tôn trọng cài OS của user:

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

Rối loạn tiền đình khiến parallax và transition lớn có hại. Cung cấp thông tin tương đương không cần motion.

Zoom, reflow và kích thước target

  • Nội dung dùng được ở zoom 200%.
  • Không scroll ngang ở viewport 320px (có ngoại lệ).
  • Target tương tác tối thiểu 24×24 CSS px (hoặc spacing đủ).

Icon button 16px không padding hit area fail audit touch và motor.


Testing — Tự động, AT và CI

Chiến lược test phân lớp

LayerTool / methodCatches
Staticeslint-plugin-jsx-a11y, TypeScript prop typesMissing alt, invalid ARIA, bad roles
Unit / componentaxe-core via @axe-core/playwright or jest-axe~57% of WCAG issues (Deque claim); zero false sense of completeness
IntegrationPlaywright/Cypress + axe after interactionsState bugs (expanded/collapsed, dialog open)
ManualKeyboard-only pass, 200% zoomFocus order, visual regressions
ATNVDA (Windows), VoiceOver (macOS/iOS), JAWS (enterprise)Announcement order, verbosity, browse vs focus mode
// axe in Playwright — run after interactions, not only on static HTML
const results = await new AxeBuilder({ page }).include('#settings-dialog').analyze();
expect(results.violations.filter((v) => v.impact === 'critical')).toEqual([]);

Test chỉ bàn phím

Rút chuột và đi hết flow chính bằng phím. Verify focus nhìn thấy, trap/restore overlay, và lỗi liên kết khi submit.

axe không bắt được gì

  • Tab order vô nghĩa nhưng DOM hợp lệ
  • Text link "Click here"
  • Timing và politeness aria-live đúng
  • Keyboard model widget custom có khớp APG

Nguyên tắc: Tự động hoá regression; validate trải nghiệm thủ công.


Checklist Principal — Ship design system

Dùng trước khi đánh dấu component stable trong design system:

AreaCheck before marking stable
Tokens4.5:1 text / 3:1 UI contrast; :focus-visible on all primitives; prefers-reduced-motion
LandmarksSkip link; header, nav, main, footer regions
Component APIDocumented keyboard map; explicit name source; disabled vs aria-disabled semantics
Composite widgetsDialog trap + restore; tabs roving tabindex; combobox aria-activedescendant; single live-region manager
Processaxe in CI; keyboard + AT smoke test per release; limitations and VPAT for regulated customers

Kết — Accessibility như thuộc tính hệ thống

Accessibility nổi lên từ semantics, keyboard, announcementinclusivity thị giác. Biết tra ở đâu, AT duyệt tree thế nào, và abstraction nào rò rỉ khi thiếu hợp đồng bàn phím.

Quy tắc đầu tiên: đừng dùng ARIA khi HTML native đủ. Quy tắc thứ hai: implement đủ APG hoặc đừng ship.

Further reading: APG · WCAG 2.2 Quick Ref · Inclusive Components · Deque University