Product Management
Đăng nhập
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Bài 26 — Dropdown / Combobox A11y

Đã đọc 0/13 mục · còn khoảng 11 phút
Cách chúng tôi ghi nhận

Một mục được tính là đã đọc khi nó nằm giữa màn hình đủ lâu so với độ dài của nó (bảng và sơ đồ tính một nửa), bạn vẫn đang cuộn hoặc bấm phím, và bạn đã thấy tới cuối mục. Lướt nhanh hoặc để tab mở không được tính. Chúng tôi chỉ lưu thời gian đọc từng mục — không lưu gì khác.

Mở đầu — vì sao bài này quan trọng

Nếu bạn quan sát bất kỳ ứng dụng nào bạn dùng hằng ngày — từ Shopee, Tiki, Momo cho tới trang ngân hàng số của Techcombank — sẽ thấy dropdown và combobox có mặt ở khắp nơi: chọn tỉnh/thành khi giao hàng, chọn ngân hàng khi liên kết thẻ, chọn danh mục sản phẩm, gõ tìm tên sân bay khi đặt vé. Đây là nhóm component xuất hiện dày đặc nhất trong form, nhưng cũng chính là nơi accessibility bị hỏng nhiều nhất.

Lý do rất đơn giản: <select> gốc của trình duyệt thì cực kỳ dễ tiếp cận nhưng lại xấu và khó style theo ý designer. Vì vậy đội ngũ frontend thường "tự chế" một dropdown bằng <div> và JavaScript để trông đẹp hơn. Và ngay khoảnh khắc đó, phần lớn các đội đánh mất toàn bộ khả năng điều hướng bằng bàn phím, mất thông báo cho screen reader, mất cả trạng thái "đang mở/đóng". Một người dùng khiếm thị hoặc chỉ dùng bàn phím sẽ đứng hình trước cái dropdown "đẹp" đó.

Bài này tập trung riêng vào ba tình huống bạn sẽ gặp: (1) <select> gốc — khi nào nên giữ, (2) custom dropdown/listbox — chọn từ danh sách cố định, và (3) combobox — ô có thể vừa gõ vừa gợi ý (autocomplete). Chúng khác nhau về vai trò ARIA, về phím bấm mong đợi, và về cách screen reader đọc. Hiểu đúng ba loại này, bạn sẽ tránh được 80% lỗi dropdown ngoài thực tế.

Khái niệm cốt lõi

1. <select> gốc — luôn cân nhắc đầu tiên

Trước khi tự chế bất cứ thứ gì, hãy tự hỏi: liệu <select> gốc có đủ không? Nó miễn phí cho bạn mọi thứ về accessibility:

<label for="country">Quốc gia</label>
<select id="country" name="country">
  <option value="">-- Chọn quốc gia --</option>
  <option value="vn">Việt Nam</option>
  <option value="kr">Hàn Quốc</option>
  <option value="jp">Nhật Bản</option>
</select>

Với đoạn này, screen reader tự đọc nhãn "Quốc gia", báo "hộp tổ hợp, danh sách", cho biết đang chọn mục nào, mục thứ mấy trên tổng số bao nhiêu. Người dùng bàn phím mở bằng Space/Enter/mũi tên, gõ chữ cái đầu để nhảy tới option. Trên điện thoại, iOS và Android hiển thị bộ chọn (picker) native tối ưu cho cảm ứng. Bạn không viết một dòng JS nào.

Điểm cần nhớ: luôn có <label> gắn với <select> qua thuộc tính for/id. Và nếu cần nhóm option, dùng <optgroup label="..."> — screen reader sẽ đọc tên nhóm.

Hạn chế thật sự của <select> là bạn gần như không thể style phần danh sách option đang xổ ra (dù CSS mới đang dần cho phép). Nếu thiết kế chỉ cần đổi màu, bo góc phần ô đóng thì <select> vẫn dư sức. Chỉ khi cần option có hình ảnh, cần tìm kiếm trong danh sách, cần multi-select với chip... bạn mới thật sự cần custom.

2. Custom dropdown = pattern "Listbox"

Khi tự chế một dropdown chọn-một-từ-danh-sách-cố-định, bạn đang xây pattern Listbox theo WAI-ARIA. Cấu trúc gồm một nút mở (button) và một danh sách (role="listbox") chứa các role="option".

Các thuộc tính ARIA bắt buộc:

  • Nút mở có aria-haspopup="listbox" và aria-expanded="true|false" (đóng/mở).
  • Nút mở dùng aria-controls trỏ tới id của listbox.
  • Danh sách có role="listbox"; mỗi mục có role="option" và aria-selected="true" cho mục được chọn.
  • Listbox có aria-activedescendant trỏ tới id của option đang được "highlight" bằng bàn phím (thay vì di chuyển focus DOM thật).
Phím bấm mong đợi (đây là hợp đồng ngầm mà người dùng bàn phím trông đợi):
  • Enter/Space/mũi tên xuống: mở danh sách.
  • Mũi tên lên/xuống: di chuyển highlight giữa các option.
  • Home/End: nhảy về đầu/cuối.
  • Gõ ký tự: nhảy tới option bắt đầu bằng ký tự đó (typeahead).
  • Enter: chọn option đang highlight, đóng danh sách, trả focus về nút.
  • Escape: đóng mà không chọn, trả focus về nút.

3. Combobox = ô input vừa gõ vừa gợi ý (autocomplete)

Combobox khác listbox ở chỗ có một ô <input type="text"> mà người dùng gõ vào, và danh sách gợi ý lọc theo nội dung gõ. Đây chính là ô "tìm tỉnh/thành", "tìm sân bay", "tìm ngân hàng".

Theo ARIA 1.2, cấu trúc chuẩn hiện nay:

  • <input role="combobox" aria-expanded="..." aria-controls="id-listbox" aria-autocomplete="list">
  • Danh sách gợi ý là một role="listbox" riêng, chứa các role="option".
  • Option đang highlight được trỏ tới bằng aria-activedescendant trên chính input — nhờ vậy focus vẫn nằm ở input (người dùng vẫn gõ được) trong khi mũi tên vẫn duyệt danh sách.
  • aria-autocomplete nhận list (chỉ gợi ý danh sách) hoặc both (gợi ý và tự điền phần còn lại vào input).
Điểm mấu chốt để hiểu combobox: focus không rời khỏi input. Người dùng gõ, danh sách hiện dưới, họ bấm mũi tên xuống — điều đó không chuyển focus DOM sang từng option, mà chỉ cập nhật aria-activedescendant. Screen reader nhờ đó vừa đọc option được chọn, vừa giữ ngữ cảnh đang ở ô nhập.

Tình huống thực tế

Ví dụ 1 — Sàn TMĐT "chọn tỉnh/thành" chặn người dùng bàn phím

Một đội sản phẩm tại một sàn thương mại điện tử Việt Nam (giả định, gọi là ChợViệt) làm lại form địa chỉ giao hàng. Designer muốn dropdown tỉnh/thành có ô tìm kiếm và hiển thị 63 tỉnh với gạch phân nhóm miền Bắc/Trung/Nam. Đội thay <select> bằng một <div> custom.

Ba tuần sau khi ra mắt, bộ phận CSKH nhận được phản ánh từ một khách hàng khiếm thị: anh không thể hoàn tất đơn vì "đến ô tỉnh thì không đi tiếp được". Đội kiểm tra lại: cái <div> dropdown không nằm trong tab order (thiếu tabindex), mở ra bằng onClick nhưng không xử lý Enter, và danh sách option là các <li> không có role. Screen reader đọc nó như một khối văn bản vô nghĩa. Tỷ lệ hoàn tất đơn của nhóm dùng bàn phím gần như bằng 0 ở bước này — nhưng vì nhóm này nhỏ nên không ai để ý qua số liệu tổng.

Cách sửa: đội chuyển nút mở thành <button> thật (tự động vào tab order, tự xử lý Enter/Space), gắn role="combobox" cho ô tìm kiếm, role="listbox"/role="option" cho danh sách, và cài aria-activedescendant để mũi tên chạy được. Bài học: cái đắt nhất không phải là code ARIA, mà là phát hiện lỗi quá muộn. Chỉ một lần test bằng phím Tab từ đầu form đến cuối đã lộ ra vấn đề — mất 5 phút thay vì mất một khách hàng.

Ví dụ 2 — Ngân hàng số và combobox "tìm ngân hàng thụ hưởng"

Một ứng dụng ngân hàng số (giả định, gọi là NganHangSo) có tính năng chuyển khoản liên ngân hàng. Ô "chọn ngân hàng thụ hưởng" là combobox: gõ "vietcom" thì hiện Vietcombank. Đội đã làm khá tốt phần điều hướng bàn phím, nhưng thiếu một chi tiết: khi danh sách lọc lại còn 1 kết quả, hoặc lọc ra 0 kết quả, screen reader hoàn toàn im lặng. Người dùng khiếm thị gõ xong không biết có gợi ý nào không, cứ đứng chờ.

Nguyên nhân: họ chưa dùng live region để thông báo số lượng kết quả. Giải pháp là thêm một vùng aria-live="polite" ẩn về mặt hình ảnh, mỗi lần lọc xong thì cập nhật nội dung kiểu "Tìm thấy 3 ngân hàng" hoặc "Không có kết quả phù hợp". Kết hợp với aria-expanded trên input để báo danh sách đang mở, trải nghiệm trở nên rõ ràng.

Con số minh họa: sau khi bổ sung, thời gian trung bình hoàn tất bước chọn ngân hàng của nhóm dùng screen reader (đo qua phiên test với 5 người dùng thật) giảm từ khoảng 48 giây xuống còn 19 giây. Bài học: với combobox, thông báo trạng thái danh sách cũng quan trọng ngang với việc duyệt được danh sách. Người sáng mắt thấy ngay "0 kết quả"; người khiếm thị cần bạn nói ra điều đó.

Ví dụ 3 — Multi-select "chọn nhiều kỹ năng" trên nền tảng tuyển dụng

Một nền tảng tuyển dụng ở Đông Nam Á cho ứng viên chọn nhiều kỹ năng bằng một combobox multi-select, mỗi lựa chọn hiện thành một "chip" có nút xóa. Đội gặp hai lỗi kinh điển. Thứ nhất, nút xóa chip chỉ là một <span> với icon "x", không có nhãn — screen reader đọc là "x" hoặc không đọc gì. Thứ hai, khi xóa một chip, focus bị văng về đầu trang vì phần tử đang focus vừa bị gỡ khỏi DOM.

Sửa: mỗi nút xóa dùng <button aria-label="Xóa kỹ năng React">, và sau khi xóa, code chủ động đưa focus về lại ô input (hoặc chip kế tiếp). Với multi-select, các option trong listbox dùng aria-selected="true" cho mục đã chọn thay vì gỡ khỏi danh sách, để người dùng biết trạng thái. Bài học: quản lý focus khi phần tử biến mất là phần dễ quên nhất — hễ bạn xóa DOM element đang giữ focus, phải quyết định focus đi đâu tiếp, nếu không người dùng bàn phím sẽ lạc.

Hướng dẫn từng bước

Dưới đây là quy trình quyết định và triển khai gọn gàng.

Bước 1 — Quyết định có cần custom không. Nếu chỉ chọn một giá trị từ danh sách và không cần tìm kiếm/hình ảnh, dùng <select> gốc. Ưu tiên này giúp bạn khỏi phải bảo trì hàng trăm dòng ARIA.

Bước 2 — Nếu buộc phải custom, chọn đúng pattern. Danh sách cố định, chọn một → Listbox. Có ô gõ + lọc → Combobox. Chọn nhiều → Listbox/Combobox multi-select với chip.

Bước 3 — Dùng đúng phần tử ngữ nghĩa cho phần mở. Nút mở là <button> thật, không phải <div onClick>. Với combobox, ô nhập là <input> thật.

<label for="skill">Kỹ năng</label>
<input id="skill" role="combobox" type="text"
       aria-expanded="false"
       aria-controls="skill-list"
       aria-autocomplete="list"
       aria-activedescendant="">
<ul id="skill-list" role="listbox" hidden>
  <li id="opt-react" role="option">React</li>
  <li id="opt-vue" role="option">Vue</li>
</ul>

Bước 4 — Nối các thuộc tính ARIA động. Khi mở: aria-expanded="true", bỏ hidden khỏi listbox. Khi highlight một option bằng mũi tên: cập nhật aria-activedescendant trỏ tới id option đó và thêm class highlight cho nó (đừng quên cả trạng thái thị giác lẫn ARIA).

Bước 5 — Cài đầy đủ bàn phím. Xử lý ArrowDown/ArrowUp/Home/End/Enter/Escape/Tab. Nhớ: Escape đóng và trả focus; Tab nên đóng danh sách rồi để tiêu điểm đi tiếp tự nhiên.

Bước 6 — Thêm live region cho combobox. Một vùng aria-live="polite" ẩn để báo số kết quả sau mỗi lần lọc.

Bước 7 — Quản lý focus khi đóng/xóa. Đóng danh sách → focus về nút/input. Xóa chip → focus về input hoặc chip lân cận. Không bao giờ để focus rơi vào hư không.

Bước 8 — Test bằng phím và screen reader trước khi merge (bài 38 và 39 sẽ đào sâu quy trình test này).

Lỗi thường gặp & mẹo

  • Dùng <div> thay cho <button>/<input>. Đây là gốc rễ của mọi rắc rối: mất tab order, mất xử lý Enter. Mẹo: nếu nó bấm được thì nó phải là <button>; nếu nó gõ được thì nó phải là <input>.
  • Thiếu aria-expanded. Screen reader không biết danh sách đang mở hay đóng. Luôn cập nhật giá trị này mỗi lần trạng thái đổi.
  • Nhầm aria-activedescendant với việc di chuyển focus thật. Trong listbox/combobox, focus DOM nên ở một chỗ (input hoặc container), việc "chọn ảo" dùng aria-activedescendant. Đừng gọi .focus() cho từng option — sẽ phá luồng gõ của combobox.
  • Quên đồng bộ trạng thái thị giác và ARIA. Bạn tô nền xanh cho option đang chọn nhưng quên aria-selected, hoặc ngược lại. Người khiếm thị và người sáng mắt phải nhận cùng một thông tin.
  • Không xử lý Escape. Người dùng bàn phím kỳ vọng Escape để thoát. Thiếu nó khiến họ mắc kẹt trong danh sách.
  • Đặt danh sách gợi ý bên trong nút mở khiến screen reader đọc lộn xộn. Giữ cấu trúc phẳng, listbox là phần tử riêng được aria-controls trỏ tới.
  • Mẹo tiết kiệm công sức: đừng viết lại từ đầu. Các thư viện headless như Downshift, React Aria (Adobe), Headless UI đã cài sẵn đúng ARIA và bàn phím theo chuẩn WAI-ARIA. Bạn chỉ style. Nhưng vẫn phải hiểu nguyên lý ở trên để biết khi nào thư viện làm sai hoặc khi bạn cấu hình lệch.
  • Mẹo test nhanh: rút chuột ra, chỉ dùng Tab và mũi tên đi hết dropdown. Nếu bạn không làm được thì người dùng bàn phím cũng không.

Bài tập thực hành

  • Audit nhanh: Chọn một trang tiếng Việt bạn hay dùng (ví dụ form đặt vé máy bay hoặc form địa chỉ giao hàng). Tìm mọi dropdown/combobox. Với mỗi cái, kiểm tra: có Tab tới được không? Enter mở được không? Mũi tên duyệt được không? Escape đóng được không? Ghi lại lỗi tìm thấy.
  • Xây một combobox tối giản: Dùng HTML/JS thuần, làm ô "chọn thành phố" gõ để lọc trong danh sách 10 thành phố Việt Nam. Yêu cầu tối thiểu: role="combobox", aria-expanded, aria-controls, aria-activedescendant, xử lý mũi tên lên/xuống, Enter, Escape, và một live region báo số kết quả.
  • So sánh với native: Làm lại đúng chức năng trên bằng <select> gốc. Bật screen reader (VoiceOver trên Mac hoặc NVDA trên Windows) và trải nghiệm cả hai. Viết 3–5 câu về khác biệt bạn nghe được.
  • Sửa lỗi focus: Tạo một multi-select có chip. Cố tình để lỗi focus văng khi xóa chip, sau đó sửa để focus quay về input. Quan sát khác biệt khi chỉ dùng bàn phím.

Tóm tắt

Dropdown và combobox là component dày đặc nhất trong form và cũng dễ hỏng accessibility nhất khi ta tự chế bằng <div>. Nguyên tắc xương sống: ưu tiên <select> gốc khi đủ dùng — nó cho bạn miễn phí toàn bộ bàn phím, screen reader và picker native trên mobile. Khi buộc phải custom, hãy nhận đúng pattern: Listbox cho danh sách cố định (nút <button> + role="listbox"/option, aria-expanded, aria-activedescendant), và Combobox cho ô gõ-để-lọc (input role="combobox", focus ở lại input, danh sách gợi ý riêng, và live region báo số kết quả).

Ba điều đừng bao giờ quên: dùng phần tử ngữ nghĩa thật (<button>, <input>), cài đầy đủ hợp đồng bàn phím (ArrowUp/Down, Home/End, Enter, Escape, typeahead), và quản lý focus mỗi khi danh sách đóng hay chip bị xóa. Cuối cùng, hãy cân nhắc thư viện headless để khỏi phát minh lại bánh xe, nhưng vẫn hiểu nguyên lý để kiểm chứng. Bài kiểm tra rẻ nhất và hiệu quả nhất vẫn là: rút chuột ra, chỉ dùng bàn phím đi hết dropdown — nếu bạn làm được, người dùng của bạn cũng làm được.

Học xong bài này rồi? Tạo tài khoản miễn phí để lưu lại — lần sau vào là biết ngay đang dở ở đâu, và học hết khóa thì có chứng chỉ. Lưu tiến độ của tôi