Hub

Khả năng truy cập

Bài này nói về a11y mức senior cho phỏng vấn: rule đầu của ARIA, bàn phím (thứ tự tab, trap, restore), HTML semantic, WCAG 2.2 AA, và CI bắt được gì / không. Dành cho engineer đã ship Vue, giờ cần tranh luận a11y với product, không phải checklist attribute.

Đọc 11.1–11.3 trước (ARIA, bàn phím, semantic). Người phỏng vấn đi một modal nhiều hơn hỏi POUR. Rồi 11.4 cho số AA và 11.5 cho axe kèm Tab thật trong Playwright. Name/role/value, bàn phím, và tương phản bắt hầu hết lỗi frontend. Axe xanh với div Pay là kịch bản lỗi.


Tóm tắt: Rule đầu của ARIA: đừng dùng ARIA nếu element native đã làm được việc đó. Role đặt tên widget, state (aria-expanded, aria-pressed) đổi, property (aria-labelledby) giữ nguyên. <div @click> với role="button" vẫn cần bàn phím và Enter/Space. Ưu tiên <button>.

Vì sao quan trọng: Người phỏng vấn đưa div Pay mà axe vẫn pass nếu bạn gắn role, rồi họ Tab. Rule đầu mới là câu chốt senior. Name, role, và value (WCAG 4.1.2) là thứ accessibility tree cần có từ bạn.

  1. Cách hoạt động: Bắt đầu với control native (button, a[href], input, dialog). Chỉ thêm ARIA để đặt tên (aria-label / aria-labelledby), mô tả (aria-describedby), hoặc expose state (aria-expanded, aria-controls, aria-invalid).
  2. role="button" trên div mà thiếu tabindex="0" và handler phím là nói dối. aria-hidden="true" trên nội dung focus được cũng vậy. Live region (aria-live="polite") đọc toast; đừng cướp focus cho “Đã lưu”.
  3. Vue: bind state, đừng hardcode aria-expanded="true". :aria-invalid="!!error || undefined" để attribute biến mất khi valid. Button chỉ icon cần accessible name. Pattern xấu kiểu Vue: <div @click="pay">Pay</div>.
<script setup lang="ts">
const open = ref(false)
</script>
<template>
  <!-- ❌ div gắn click không phải button. Axe vẫn có thể pass nếu có role. -->
  <!-- <div role="button" @click="open = !open">Account</div> -->
  <button type="button" :aria-expanded="open" aria-controls="user-menu" @click="open = !open">
    Account
  </button>
  <ul v-show="open" id="user-menu" role="menu">
    <li role="none"><a role="menuitem" href="/billing">Billing</a></li>
  </ul>
</template>

Điều gì xảy ra: <button> thật thì focus được, click bằng Enter/Space, và expose aria-expanded khi menu mở. div trong comment cần implement cả bàn phím mà vẫn thiếu semantics type="button". Axe có thể xanh cả hai cách. Tab thì không.

CầnNative trướcARIA chỉ khi
Hành động<button type="button">Bạn không đổi được tag
Điều hướng<a href>Link giả SPA: vẫn cần href
Đặt tên widget<label> / heading hiện trên UIaria-labelledby / aria-label
State<dialog open> / checkboxaria-expanded / aria-pressed

Góc nhìn senior

  • Rule đầu, rồi name/role/value. Đọc thuộc tính là câu chốt junior.
  • aria-label thắng text hiện trên nhiều AT. Đừng nhân đôi một tên sai.
  • role="presentation" / aria-hidden trên node focus được là bẫy. Ẩn hẳn, hoặc gỡ khỏi tab order.
  • Live region cho status, không phải mọi phím. Toast polite; assertive cho lỗi chặn.
  • Axe xanh không phải rule đầu. Tab control Pay.

Lỗi hay gặp

  • Triệu chứng: Tab bỏ qua Pay. Nguyên nhân: div @click với role. Cách xử lý: <button>.
  • Triệu chứng: SR nói “button” mở mãi. Nguyên nhân: hardcode aria-expanded="true". Cách xử lý: bind nó.
  • Triệu chứng: hai tên. Nguyên nhân: text hiện kèm aria-label sai. Cách xử lý: một tên.
  • Triệu chứng: focus được nhưng bị ẩn. Nguyên nhân: aria-hidden trên tab stop. Cách xử lý: inert hoặc gỡ tabindex.

Nhà tuyển dụng hay hỏi gì

Layout Vue ship menu toàn <div @click>. Sao không chỉ gắn role ARIA lên những div đó? Họ muốn rule đầu, rồi name/role/value, rồi Tab.

Trả lời khi phỏng vấn

Rule đầu của ARIA: đừng dùng ARIA nếu element native đã làm được việc đó. Mình ship button, không phải div với role button. Role đặt tên widget, state đổi, property giữ nguyên. aria-expanded được bind, không hardcode. Control chỉ icon thì có accessible name. Name, role, và value là WCAG 4.1.2. Axe có thể xanh trên div Pay; Tab sẽ fail. Live region đọc toast mà không cướp focus. Nếu không đổi được tag, mình gắn role và cũng gắn bàn phím.

Nhớ nhanh

  • Rule đầu của ARIA: HTML native trước. <button> hơn role="button".
  • Role đặt tên, state đổi, property giữ nguyên. Bind aria-expanded.
  • Name/role/value là WCAG 4.1.2. Axe xanh không phải Tab.
  • Control chỉ icon cần accessible name.

Câu hỏi nối tiếp thường gặp

  • div role button không tabindex? Không nằm trong tab order. Vẫn thiếu Enter và Space.
  • aria-label khác text trên UI? Label thắng trên nhiều AT. Đừng ship một tên xung đột.
  • Khi nào ARIA bắt buộc? Widget custom không native: tab, combobox, tree.
  • aria-live cho đã lưu? Polite, không cướp focus. Lỗi chặn có thể assertive.

Tóm tắt: User bàn phím phải tới được mọi hành động pointer. Thứ tự tab là thứ tự DOM. tabindex="0" thì hiếm; lớn hơn 0 thì không bao giờ. Modal: lưu opener, đưa focus vào, trap, Escape đóng, restore. Widget composite dùng roving tabindex. Xem 11.5 cho Tab Playwright.

Vì sao quan trọng: “Walk me through a modal” tách senior: Edit billing, Tab không được ra trang phía sau, Escape đóng, focus trả về opener. focus-visible khác :focus { outline: none } toàn cục là comment review được merge.

  1. Cách hoạt động: Tab đi qua tab stop theo thứ tự DOM. Skip link là focusable đầu, trỏ main. <dialog>.showModal() native thì trap, vẽ ::backdrop, và xử lý Escape. nextTick rồi focus control có nghĩa đầu (không phải icon đóng nếu có field title).
  2. Nếu không dùng được <dialog>, Teleport tới body, gán inert cho sibling của #app (aria-modal không đủ trên SR cũ), trap Tab/Shift+Tab, lắng nghe Escape. Lúc đóng: opener.focus(), kèm fallback nếu list re-render làm mất opener.
  3. Roving tabindex: một 0, sibling -1, mũi tên chuyển, Home/End, typeahead trên list dài. Tab rời composite. Tooltip ở lại trigger. Tương tự Vue: watch(open) rồi nextTick trước showModal().
<script setup lang="ts">
const open = ref(false)
const dialog = ref<HTMLDialogElement | null>(null)
const opener = ref<HTMLElement | null>(null)
watch(open, async (v) => {
  if (v) {
    opener.value = document.activeElement as HTMLElement
    await nextTick()
    dialog.value?.showModal() // trap + backdrop + Esc
  } else {
    dialog.value?.close()
    opener.value?.focus() // restore; fallback nếu row unmount
  }
})
</script>
<template>
  <a href="#main" class="skip-link">Skip to main content</a>
  <button type="button" @click="open = true">Edit billing</button>
  <dialog ref="dialog" @close="open = false"><slot /></dialog>
</template>

Điều gì xảy ra: Click Edit billing lưu opener, mở dialog modal (trap của browser), và lúc đóng restore focus. Skip link đứng đầu DOM. Dropdown bên trong vẫn phụ trách mũi tên; Tab là việc của trap.

WidgetFocus tớiTrap?
Modal / <dialog>Control có nghĩa đầu sau nextTickCó
TooltipỞ lại triggerKhông: Esc đóng
Listbox / tabs / menuRoving tabindex + mũi tênKhông: Tab rời
Skip linkTab stop đầu → #mainKhông

Góc nhìn senior

  • tabindex > 0 thì không bao giờ. Bạn sẽ đánh nhau với mọi widget về sau.
  • Restore fail khi opener unmount (list bị filter). Giữ fallback (#main hoặc toolbar).
  • focus-visible là comment review senior khi design gỡ :focus. Trả ring chỉ cho bàn phím.
  • Mũi tên combobox bên trong khác trap: composite phụ trách mũi tên; modal phụ trách Tab.
  • Chứng minh bằng phím Tab thật Playwright (11.5), không phải element.focus() trong unit test.

Lỗi hay gặp

  • Triệu chứng: Tab ra được trang phía sau. Nguyên nhân: không trap / không showModal(). Cách xử lý: <dialog> hoặc inert + trap.
  • Triệu chứng: mất focus lúc đóng. Nguyên nhân: opener unmount. Cách xử lý: đích fallback.
  • Triệu chứng: mọi hàng đều là tab stop. Nguyên nhân: không roving tabindex. Cách xử lý: một 0, mũi tên chuyển.
  • Triệu chứng: không outline. Nguyên nhân: :focus { outline: none } toàn cục. Cách xử lý: :focus-visible.

Nhà tuyển dụng hay hỏi gì

Đi qua một modal với mình: Edit billing, Tab không được đụng trang phía sau, Escape đóng, focus trả về. Rồi roving tabindex và skip link.

Trả lời khi phỏng vấn

User bàn phím tới được mọi hành động con trỏ. Thứ tự tab là thứ tự DOM: tabindex 0 thì hiếm, lớn hơn 0 thì không bao giờ. Với modal, mình lưu opener, Teleport tới body, dùng dialog native showModal khi được, nextTick rồi focus control có nghĩa đầu tiên, bẫy Tab, Escape đóng, rồi focus lại opener. Opener unmount thì mình fallback về main. Widget tổng hợp dùng roving tabindex: một tab stop, mũi tên dịch. Skip link đứng đầu. Design gỡ outline thì mình trả lại focus-visible. Mình chứng bằng phím Tab thật trên Playwright.

Nhớ nhanh

  • Thứ tự tab là thứ tự DOM. tabindex > 0 không bao giờ.
  • Modal: opener, vào, bẫy, Escape, trả. Ưu tiên <dialog>.showModal().
  • Roving tabindex cho widget tổng hợp. Tab rời. Skip link đứng đầu.
  • Chứng minh bằng phím Tab thật. Trả :focus-visible.

Câu hỏi nối tiếp thường gặp

  • Test trong CI thế nào? Playwright, Tab và Escape thật. 11.5. Không element.focus trong jsdom.
  • Dropdown trong modal? Widget bên trong lo mũi tên. Modal lo Tab.
  • aria-modal không đủ? Screen reader cũ. Gán inert lên sibling của app.
  • Opener unmount? Fallback về main hoặc toolbar. Đừng focus body thầm.

Tóm tắt: Cây accessibility phải trông như một tài liệu: một main, header / nav / aside / footer, outline heading hợp lý, list cho list, table cho table. Phần tử native cho sẵn hành vi bàn phím và SR. Landmark ARIA là backup khi bạn không đổi được tag, không phải mặc định của design system.

Vì sao quan trọng: Layout Vue ship bốn <h1>, menu toàn <div @click>, và hàng invoice là div. Họ hỏi landmark, outline heading, list khác div, và “sao không gắn role ARIA lên mấy div đó?” Xem 11.1 cho rule đầu.

  1. Cách hoạt động: Một <h1> mỗi view (đổi route thì reset). Widget là <section> có heading, không lồng <article> vì mốt. Invoice là list hoặc table có header. CSS Flex vẫn chạy trên <li>. RouterLink vẫn nằm trong <li>.
  2. Đừng để main ở cả layout Nuxt lẫn trang. <nav> chỉ SVG cần accessible name (aria-label="Primary"). Skip link kèm id="main" hoàn tất vòng từ 11.2.
  3. HTML PDF/export vẫn cần cây heading. role="navigation" trên div làm mặc định DS tệ hơn <nav>: thêm ARIA, cùng CSS. Analog Vue: giữ một <h1> ở trang, không phải mọi component card.
<template>
  <a href="#main" class="skip-link">Skip to main content</a>
  <header>
    <nav aria-label="Primary">
      <ul>
        <li><RouterLink to="/">Home</RouterLink></li>
      </ul>
    </nav>
  </header>
  <main id="main">
    <h1>Invoices</h1>
    <section aria-labelledby="overdue-heading">
      <h2 id="overdue-heading">Overdue</h2>
      <ul>
        <li v-for="inv in overdue" :key="inv.id">{{ inv.number }}</li>
      </ul>
    </section>
  </main>
</template>

Điều gì xảy ra: Một banner, một nav chính có tên, một main, một h1, một section với h2, một list invoice. Cây accessibility trông như tài liệu. CSS vẫn Flex ul được.

ViệcPhần tửKịch bản lỗi
Tiêu đề trang sau khi đổi routeMột <h1> mỗi viewMỗi card một <h1>
Chromeheader nav main aside footermain ở layout và trang
Invoice / nav item<ul> / <table> với headerRouterLink không <li>
Nav có tên<nav aria-label="Primary">Nav chỉ SVG, không tên

Góc nhìn senior

  • Semantic là hành vi có sẵn. ARIA trên div soup là hành vi trả phí, bạn sẽ quên gắn bàn phím.
  • Nhảy cấp heading vì look là mê cung SR. Style lại h2; đừng giả size h1.
  • Widget dashboard: section + heading. Đừng lồng article chỉ vì blog từng dùng.
  • Hai main sau khi layout Nuxt ghép là bug landmark. Đếm chúng trên một route.
  • Giữ CSS độc lập: Flex/Grid trên tag semantic, không làm ngược.

Lỗi hay gặp

  • Triệu chứng: bốn h1. Nguyên nhân: component card ship h1. Cách xử lý: heading là prop, một h1 trên trang.
  • Triệu chứng: hai main. Nguyên nhân: layout + trang. Cách xử lý: một owner.
  • Triệu chứng: SR nói “group” trên invoice. Nguyên nhân: hàng div. Cách xử lý: ul/li hoặc table.
  • Triệu chứng: nav không tên. Nguyên nhân: chỉ icon SVG. Cách xử lý: aria-label.

Nhà tuyển dụng hay hỏi gì

Bốn h1, menu toàn div click, hàng invoice là div: landmark, outline heading, list khác div, và sao không gắn role ARIA lên mấy div đó?

Trả lời khi phỏng vấn

Cây accessibility phải trông như một tài liệu: một main, header nav aside footer, outline heading hợp lý, list cho list, table cho table. Semantic cho sẵn hành vi bàn phím và screen reader. Landmark ARIA là backup khi mình không đổi được tag, không phải mặc định của design system. Mình không để main ở cả layout Nuxt lẫn trang. Một h1 mỗi view sau khi đổi route. Invoice là list hoặc table; CSS Flex vẫn chạy. Sao không gắn role lên mấy div đó chính là rule đầu của ARIA ở 11.1.

Nhớ nhanh

  • Một main, một <h1> mỗi view, list và table thật.
  • Landmark native hơn role="navigation" làm mặc định DS.
  • Widget là section + heading. Đừng lồng article vì mốt.
  • CSS Flex/Grid vẫn chạy trên tag semantic.

Câu hỏi nối tiếp thường gặp

  • Dashboard widget? Mỗi cái là section có heading. Đừng lồng article vì mốt.
  • HTML PDF/export? Vẫn cần cây heading nếu nó là HTML.
  • role navigation trên div khác nav? nav là mặc định DS. Role là backup.
  • Một h1 sau khi đổi route Vue? Trang lo phần đó. Card nhận heading qua prop.

Tóm tắt: WCAG 2.2 AA là bar thường gặp trong contract. POUR (Perceivable, Operable, Understandable, Robust) là cách nói, không phải bài đọc thuộc. Name/role/value, bàn phím, và tương phản (4.5:1 chữ, 3:1 chữ lớn/UI) bắt hầu hết lỗi frontend. Axe tìm được khoảng một nửa. AAA không phải target phủ hết.

Vì sao quan trọng: Họ hỏi A khác AA khác AAA và số tương phản, rồi bạn test checkout thế nào, không phải đọc thuộc POUR. Một người trong team nói “bọn mình chạy axe trong CI nên đã AA”. Câu đó là miss.

  1. Cách hoạt động: AA là bar (EU EAA, nhiều lawsuit ở Mỹ, hầu hết RFP enterprise). POUR là khung. Test name/role/value (4.1.2), bàn phím (2.1.1), tương phản (1.4.3 / 1.4.11), kèm phần 2.2 bạn hay đụng: 2.4.11 focus not obscured, 2.5.8 target size.
  2. Tương phản: đo cặp computed, không phải folklore hex. Chữ lớn (≥18pt / 14pt đậm) là 3:1. Component UI và focus ring là 3:1 với màu kề. outline: none không có :focus-visible thì fail 2.

    • 4.7. Reflow 1.4.10: zoom 400%, không cuộn hai chiều cho chữ.
  3. Mang label “hint” xám trên trắng sang design như một trận token. prefers-contrast / forced-colors: icon background-image biến mất, ship SVG hoặc chữ. Thông báo route: live region polite ẩn khỏi mắt, sau afterEach của Vue Router.
<label for="email">Email</label>
<input
  id="email"
  type="email"
  :aria-invalid="!!emailError || undefined"
  :aria-describedby="emailError ? 'email-err' : undefined"
/>
<p v-if="emailError" id="email-err" role="alert">{{ emailError }}</p>
<div class="sr-only" aria-live="polite">{{ routeAnnounce }}</div>
<!-- Toast: aria-live="polite"; đừng cướp focus cho “đã lưu”. -->
<!-- Lỗi chặn có thể assertive / role="alert". -->
<!-- Tương phản: đo cặp computed. Hint xám trên trắng là trận token. -->

Điều gì xảy ra: Field có name nhìn thấy, state invalid, và lỗi được mô tả. Đổi route thì thông báo, không chuyển focus. Tương phản và focus ring là việc token, không phải directive Vue. Axe vẫn không chứng được bàn phím hay zoom 400%.

Nội dungTỉ lệ AAGhi chú
Chữ thường4.5:1Đo cặp computed, không folklore hex
Chữ lớn (≥18pt / 14pt đậm)3:1AAA 7:1 là dự án token
UI / đồ họa (1.4.11)3:1 với màu kềGồm focus ring
Reflow (1.4.10)Zoom 400%Không cuộn 2D cho chữ

Góc nhìn senior

  • Axe trong CI không phải AA. Axe bắt được khoảng một nửa. Bàn phím + một vòng SR mới khép câu.
  • Cãi với product bằng token và lawsuit, không phải đổ tội. Label hint xám fail 4.5:1: đó là token Figma.
  • Tương phản AAA 7:1 là dự án brand/token nếu họ hỏi, không phải plugin Vue.
  • Sticky header 2.4.11 che focus là miss 2.2 bạn sẽ đưa lên production.
  • overflow-x: hidden trên body (3.6) cũng là miss 1.4.10.

Lỗi hay gặp

  • Triệu chứng: “axe xanh nên là AA”. Nguyên nhân: máy miss bàn phím và SR. Cách xử lý: 11.5.
  • Triệu chứng: chữ hint fail tương phản. Nguyên nhân: token xám trên trắng. Cách xử lý: design, không phải directive.
  • Triệu chứng: focus bị sticky che. Nguyên nhân: 2.4.11. Cách xử lý: scroll-margin / header thấp hơn.
  • Triệu chứng: zoom 400% cuộn hai trục. Nguyên nhân: overflow-x: hidden trên body. Cách xử lý: 3.6.

Nhà tuyển dụng hay hỏi gì

A khác AA khác AAA và các số tương phản, rồi bạn test checkout thế nào. Một teammate bảo axe trong CI nghĩa là đã AA.

Trả lời khi phỏng vấn

WCAG 2.2 AA là bar mình nêu: EU EAA, lawsuit, RFP enterprise. AAA không phải mục tiêu phủ hết. Mình nói POUR, nhưng mình test name, role, value, bàn phím, và tương phản, vì những cái đó bắt hầu hết lỗi frontend. Axe trong CI không phải AA; axe bắt được khoảng một nửa. Label hint xám trên trắng thì mình đưa sang design, đánh ở tầng token. Mình đo cặp computed: 4.5 trên 1 cho chữ, 3 trên 1 cho chữ lớn và UI. Extra 2.2 mình hay đụng là focus không bị che và target size. Zoom 400 phần trăm là reflow, không phải screenshot.

Nhớ nhanh

  • WCAG 2.2 AA là bar. AAA không phải mục tiêu phủ hết.
  • Name/role/value + bàn phím + tương phản bắt hầu hết lỗi FE.
  • Chữ 4.5:1. Large/UI 3:1. Đo cặp computed.
  • Axe trong CI không phải AA. Axe bắt được khoảng một nửa.

Câu hỏi nối tiếp thường gặp

  • Extra WCAG 2.2 bạn hay đụng? 2.4.11 focus not obscured, 2.5.8 target size.
  • AAA 7:1? Dự án token nếu brand hỏi, không phải directive Vue.
  • forced-colors? Icon background-image biến mất. Ship SVG hoặc chữ.
  • 1.4.10 Reflow? Zoom 400%, không cuộn 2D cho chữ. overflow-x hidden trên body thì fail.

Tóm tắt: CI bắt regression máy thấy được; người bắt phần còn lại. Pipeline: lint (vuejs-accessibility / jsx-a11y), axe-core trong Vitest (DS) và Playwright (checkout), Tab/Escape thật trên trap, tương phản qua token/screenshot (axe trong jsdom miss computed style). Axe xanh với một div Pay là kịch bản lỗi.

Vì sao quan trọng: “Làm sao để cái này không mục?” Cái gì thuộc CI, cái gì là một vòng VoiceOver, và cái gì sẽ bounce PR Drawer design system khi không có story focus? DS chịu trách nhiệm primitive; product chịu trách nhiệm composition và copy.

  1. Cách hoạt động: (1) eslint-plugin-vuejs-accessibility (hoặc jsx-a11y trên Next) trên PR. (2) axe-core trong Vitest cho component DS và @axe-core/playwright cho trang critical, gắn tag wcag2a / wcag2aa / wcag22aa. (3) Playwright bàn phím thật trên modal/combobox.
  2. (4) Tương phản qua token DS / screenshot, không đoán hex bằng mắt: axe trong jsdom miss computed style. (5) PR DS: story Storybook cho bàn phím, tương phản đảo, reduced motion, và focus trap viết ra. Allowlist được version và review; không phải /checkout mãi.
  3. Vue legacy: Playwright + axe trên login/checkout trước (giống testing 8.6), eslint warn→error. i18n: query role/label từ fixture, không phải chuỗi tiếng Anh. Overlay Teleport false-positive nếu scan trước khi dialog mở. Widget bên thứ ba thì contract test, đừng hy vọng.
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
test('checkout has no serious axe violations', async ({ page }) => {
  await page.goto('/checkout')
  const results = await new AxeBuilder({ page })
    .withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
    .analyze()
  expect(results.violations).toEqual([])
})
test('billing dialog traps focus', async ({ page }) => {
  await page.goto('/settings')
  await page.getByRole('button', { name: 'Edit billing' }).click()
  await page.keyboard.press('Tab')
  await expect(page.getByRole('dialog')).toBeFocused() // hoặc control đầu tiên bên trong
})

Điều gì xảy ra: Checkout không được có vi phạm axe đã gắn tag. Dialog billing thì chứng bằng phím Tab thật, không phải element.focus(). Axe unit jsdom sẽ miss cả trap lẫn tương phản computed.

TầngBắt đượcBỏ sót
Plugin eslint a11yAnti-pattern tĩnh trên PRFocus runtime, tương phản, lời SR
axe trong Vitest / PlaywrightName/role/value, nhiều rule A/AATương phản jsdom; trap bàn phím; VoiceOver
Tab / Esc thật PlaywrightTrap modal, trả focus, mũi tên comboboxAT nói gì
SR tay mỗi releaseLời SR modal/listbox DSMọi commit: đừng block cái này

Góc nhìn senior

  • Bounce PR Drawer không có story focus: không trả opener, không Esc, không story bàn phím Storybook. Đó là gate DS.
  • Ai chịu trách nhiệm bug: DS cho primitive; product cho composition và copy. Đừng ping-pong.
  • Allowlist mục dần. Review như code. /checkout không phải mãi.
  • CI i18n: getByRole('button', { name: fixture.editBilling }), không phải tiếng Anh.
  • Axe xanh + div Pay là 11.1. Giữ test Tab dù axe im.

Lỗi hay gặp

  • Triệu chứng: axe xanh, Tab bỏ Pay. Nguyên nhân: CI chỉ có axe. Cách xử lý: test bàn phím thật.
  • Triệu chứng: overlay Teleport false-positive. Nguyên nhân: scan trước khi mở. Cách xử lý: mở, rồi analyze.
  • Triệu chứng: CI FR fail tên. Nguyên nhân: locator tiếng Anh. Cách xử lý: fixture.
  • Triệu chứng: Drawer merge không trả focus. Nguyên nhân: không có gate a11y DS. Cách xử lý: Storybook + trap Playwright.

Nhà tuyển dụng hay hỏi gì

Làm sao để a11y không mục? Phần nào vào CI, phần nào là một vòng VoiceOver, và cái gì sẽ bounce PR Drawer design system khi không có story focus?

Trả lời khi phỏng vấn

CI bắt cái máy thấy được; người bắt phần còn lại. Mỗi PR mình muốn vuejs-accessibility hoặc jsx-a11y, axe-core trong Vitest cho component DS và Playwright cho checkout, và Tab cùng Escape thật trên mọi thứ có trap hoặc mũi tên. Tương phản chạy trong Playwright hoặc Storybook: axe trong jsdom miss computed style. Mình allowlist có review, không phải mãi. PR Drawer không có story focus thì bounce. DS chịu trách nhiệm primitive; product chịu trách nhiệm composition. Axe xanh với div Pay là lý do mình giữ test Tab. VoiceOver mỗi release một lần, không phải mọi commit.

Nhớ nhanh

  • Lint + axe + Tab thật trong CI. SR tay mỗi release.
  • Axe trong jsdom miss tương phản và trap. Playwright chịu những cái đó.
  • Bounce Drawer DS không có story focus. DS primitive, product composition.
  • Axe xanh + div Pay là kịch bản lỗi. Giữ test Tab.

Câu hỏi nối tiếp thường gặp

  • Ai chịu trách nhiệm bug a11y? DS cho primitive; product cho composition và copy.
  • App Vue legacy? Playwright kèm axe trên login/checkout trước; eslint warn rồi error.
  • Locator i18n? Fixture, không phải tên tiếng Anh, không thì CI FR gãy.
  • Teleport false-positive? Scan sau khi dialog mở. Bên thứ ba thì contract test.