Skip to content

บทที่ 12 — Accessibility (a11y) ใน Angular

← บทที่ 11: Micro-frontends | สารบัญ | บทที่ 13: Security →

📌 คำว่า a11y = "accessibility" (มี 11 ตัวอักษรระหว่าง a และ y) — เป็น shorthand สากล

📋 ต้องรู้มาก่อน: Angular component + template + reactive forms พื้นฐาน (บทที่ 3-6) — ถ้ายังไม่ผ่านบทเหล่านั้น แนะนำอ่านก่อน ส่วนเรื่อง RxJS (.pipe(), .subscribe()) ที่ใช้ในหัวข้อ FocusMonitor และ Routing จะมีคำอธิบายสั้น ๆ กำกับไว้ในเนื้อหา — ยังไม่เคยเรียน RxJS มาก่อนก็อ่านต่อได้ (RxJS เต็มอยู่ในบท Reactive Programming)

หลังจบบท คุณจะ:

  • เข้าใจว่า a11y คืออะไร + ทำไมสำคัญ (โดยเฉพาะใน enterprise / government)
  • เขียน semantic HTML + ARIA ที่ถูกต้อง
  • จัดการ keyboard navigation + focus management
  • ใช้ Angular CDK a11y utilities (LiveAnnouncer, FocusTrap, FocusMonitor)
  • ทดสอบแอปด้วย screen reader + automated tool
  • Audit + แก้ a11y problem ใน Angular app

ใช้เวลา 2-3 ชั่วโมง


1. ทำไม Accessibility สำคัญ

Accessibility คือการทำให้แอปใช้งานได้สำหรับทุกคน รวมถึงคนที่ตาบอด (ใช้ screen reader อ่านหน้าจอแทน), หูหนวก (ต้องการ caption แทนเสียง), ใช้คีย์บอร์ดแทนเมาส์ หรือใช้ voice control — ไม่ใช่แค่ "กลุ่มน้อย" แต่เป็น ~15% ของประชากรโลก

text
📊 ตัวเลข (ปี 2026):
- ~15% ของประชากรโลกมี disability (ความพิการ) บางรูปแบบ
- ~1 พันล้านคน ใช้ assistive technology (เทคโนโลยีช่วยเหลือ — เช่น screen reader, voice control, switch)
- เว็บไซต์ส่วนใหญ่ FAIL WCAG ระดับ AA basic

📌 ศัพท์: switch = อุปกรณ์รับสัญญาณกด (ปุ่มใหญ่ ๆ หรือเซ็นเซอร์) สำหรับผู้ที่ใช้คีย์บอร์ดไม่ได้

กฎหมาย + Compliance

text
🇪🇺 EU: European Accessibility Act (มีผล มิ.ย. 2025)
🇺🇸 US: ADA (Americans with Disabilities Act) + Section 508 (รัฐบาล)
🇬🇧 UK: Equality Act 2010
🇯🇵 JIS X 8341-3
🇹🇭 พ.ร.บ. ส่งเสริมและพัฒนาคุณภาพชีวิตคนพิการ (รัฐบาลไทย — ภาครัฐมี WCAG 2.0 AA guideline แต่ยังไม่บังคับ WCAG level ชัดเจนสำหรับ private sector ณ ปี 2026)

ถ้าทำ enterprise / banking / government:

  • ต้อง audit (ตรวจสอบและประเมิน) ให้ผ่าน WCAG 2.1 AA (หรือ AAA)
  • โดน sue ได้ถ้าไม่ทำ (US: เคยมี cases หลายพันคดี)

ประโยชน์ที่ได้ "ฟรี"

text
✅ SEO ดีขึ้น — semantic HTML + ARIA ทำให้ Google bot เข้าใจโครงสร้างหน้า → จัดอันดับดีขึ้น
✅ UX ดีกับทุกคน — keyboard shortcut, focus indicator (กรอบเห็น focus) ช่วย power user ด้วย
✅ Mobile-friendly — touch target ใหญ่ (≥ 44px) คนนิ้วหนาก็กดง่าย
✅ Internationalization ง่าย — โครงสร้าง semantic ชัด → แปลภาษาง่ายขึ้น
✅ ทดสอบอัตโนมัติได้ดี — selector อิง role/label แทน CSS class → e2e test เสถียรกว่า

2. WCAG — มาตรฐานหลัก

text
WCAG = Web Content Accessibility Guidelines (W3C = World Wide Web Consortium — องค์กรที่กำหนดมาตรฐานเว็บ เช่น HTML, CSS, ARIA)
ปัจจุบัน: WCAG 2.2 (2023), 3.0 ยังเป็น draft

หลัก 4 ข้อ (POUR = ตัวย่อจากชื่อ 4 หลักด้านล่าง P + O + U + R):
- Perceivable    — รับรู้ได้ (เห็น/ได้ยิน/สัมผัส)
- Operable        — ใช้งานได้ (keyboard, mouse, voice)
- Understandable  — เข้าใจได้ (clear, predictable)
- Robust          — เข้ากันได้กับ assistive tech

ระดับ:
- A   — ขั้นต่ำ
- AA  — มาตรฐาน (กฎหมายส่วนใหญ่บังคับ)
- AAA — สูงสุด (เฉพาะส่วน)

หัวข้อสำคัญใน WCAG AA

WCAG แบ่งเกณฑ์เป็นเลขรหัส เช่น 1.1.1 — ไม่ต้องจำเลขรหัส แค่รู้ว่ามันคือ "checklist ข้อย่อย" ที่บอกว่าเว็บต้องทำอะไรบ้างเพื่อให้ทุกคนใช้ได้ ตารางด้านล่างเลือกมาแค่เกณฑ์ที่ส่งผลกับโค้ด Angular โดยตรง — และเพิ่มตัวอย่างว่า "ถ้าทำผิดจะเกิดอะไร" ให้เห็นภาพ:

หัวข้อสิ่งที่ต้องทำถ้าทำผิด
1.1.1 Text alternatives<img> ทุกตัวมี altscreen reader อ่านชื่อไฟล์ img3847.jpg แทน — ผู้ใช้งงว่ารูปคืออะไร
1.3.1 Info + relationshipsSemantic HTML, ARIA rolesใช้ <div> ทั้งหมด → screen reader อ่าน "div div div" ไม่รู้ว่าส่วนไหนคือเมนู ส่วนไหนคือเนื้อหา
1.4.3 Contrastสีพื้น/อักษร contrast ratio ≥ 4.5:1 (small) / 3:1 (large)ตัวอักษรสีเทาอ่อนบนพื้นขาว (#aaa on #fff) → ผู้สูงอายุและสายตาเลือนรางอ่านไม่ออก
2.1.1 Keyboardทำได้ทุกอย่างด้วย keyboardผู้ใช้ keyboard-only กด Tab ไปถึงปุ่ม "ลบ" แต่กด Enter แล้วไม่มีอะไรเกิดขึ้น → ใช้งานไม่ได้เลย
2.4.3 Focus orderTab order ตรงตามที่เห็นTab กระโดดจากหัวข้อไปท้ายหน้าข้ามเนื้อหากลาง → ผู้ใช้หลงทาง
2.4.7 Focus visibleเห็น focus indicatorใส่ outline: none ทุก element → กด Tab ไม่รู้ว่า focus อยู่ที่ไหน
3.3.2 Labelsทุก input มี label<input placeholder="Email"> ไม่มี label → screen reader ไม่รู้จะอ่านว่าอะไร และ placeholder หายทันทีที่พิมพ์
4.1.2 Name, role, valueARIA attributes correctปุ่ม icon-only ไม่มี aria-label → screen reader อ่านว่า "button" เฉย ๆ ไม่รู้ว่าทำอะไร

3. Semantic HTML — รากฐาน

ก่อนคิดถึง ARIA — ใช้ HTML semantic เป็นอันดับแรก

❌ Div Soup

html
<div class="header">
    <div class="nav">
        <div class="link" (click)="goHome()">Home</div>
        <div class="link" (click)="goAbout()">About</div>
    </div>
</div>
<div class="main">
    <div class="title">Welcome</div>
    <div class="content">...</div>
</div>

→ Screen reader: "div, div, div..." — ไม่รู้ว่าอะไรเป็นอะไร

✅ Semantic

html
<header>
    <nav aria-label="Main navigation">
        <a routerLink="/">Home</a>
        <a routerLink="/about">About</a>
    </nav>
</header>

<main>
    <h1>Welcome</h1>
    <p>...</p>
</main>

<aside aria-labelledby="sidebar-title">
    <h2 id="sidebar-title">Related</h2>
    ...
</aside>

<footer>
    <p>&copy; 2026 Acme Corp</p>
</footer>

→ Screen reader announce: "Banner. Navigation: Main navigation. Link: Home..."

Tag ที่ต้องรู้

Tagใช้ตอนไหน
<header>top of page / section
<nav>navigation links
<main>primary content (1 ต่อ page)
<section>grouping with heading
<article>self-contained content
<aside>tangential content (sidebar)
<footer>bottom
<button>actions (click)
<a>navigation (links)
<form>, <label>, <input>forms
<h1>-<h6>hierarchical headings
html
<!-- ❌ Wrong -->
<a (click)="submit()">Submit</a>        <!-- ไม่ใช่ link -->
<div (click)="goHome()">Home</div>      <!-- ไม่ใช่ button -->

<!-- ✅ Right -->
<button (click)="submit()" type="button">Submit</button>
<a routerLink="/">Home</a>

→ Rule: button = action ในหน้านี้, link = ไปหน้าอื่น/ที่อื่น


4. Heading Hierarchy

หัวข้อ (<h1><h6>) ไม่ได้มีไว้แค่ทำตัวอักษรใหญ่ แต่เป็น "สารบัญ" ที่ผู้ใช้ screen reader ใช้กระโดดสำรวจทั้งหน้า ดังนั้นต้องเรียงลำดับให้ถูก (ห้ามข้ามระดับ) และเลือกใช้ตามความหมาย ไม่ใช่ตามขนาดที่อยากได้ (ขนาดให้ใช้ CSS):

html
<!-- ✅ Logical hierarchy -->
<h1>Page Title</h1>
    <h2>Section A</h2>
        <h3>Subsection A.1</h3>
        <h3>Subsection A.2</h3>
    <h2>Section B</h2>

<!-- ❌ Skip levels -->
<h1>Page Title</h1>
    <h3>Section A</h3>           <!-- skip h2 — bad! -->

<!-- ❌ Choose for size -->
<h2>Subtitle</h2>                <!-- เพราะอยาก font 24px? ใช้ CSS แทน -->

→ Screen reader user navigate ทั้ง page ด้วย heading list

typescript
// VS Code Extension: "axe Accessibility Linter" จับ heading issue auto

5. Alt Text สำหรับรูป

alt คือข้อความที่ screen reader จะอ่านแทนรูปให้ผู้ใช้ที่มองไม่เห็น หลักคือเขียนให้สื่อ "ความหมาย" ของรูป ไม่ใช่บอกว่า "นี่คือรูป" ส่วนรูปที่เป็นแค่การตกแต่งให้ใส่ alt="" เพื่อให้ screen reader ข้ามไป:

html
<!-- ✅ Informative image -->
<img src="chart.png" alt="Sales increased 40% from Q1 to Q2">

<!-- ✅ Decorative image (รูปตกแต่ง — ไม่มีข้อมูล) -->
<img src="divider.png" alt="" role="presentation">
<!-- role="presentation" = บอก screen reader ว่า element นี้แค่ตกแต่ง ให้ข้ามไปได้ -->

<!-- ✅ Logo with link -->
<a routerLink="/">
    <img src="logo.svg" alt="Acme Corp home">
</a>

<!-- ✅ Complex image -->
<img src="diagram.svg" alt="Architecture overview" 
     aria-describedby="diagram-desc">
<p id="diagram-desc" class="visually-hidden">
    Detailed description: Client → API Gateway → 3 microservices → database
</p>

<!-- ❌ Useless alt -->
<img src="img1.jpg" alt="image">
<img src="dog.jpg" alt="picture of dog">     <!-- ไม่บอกอะไร -->

Rules

text
✅ alt บอกความหมาย ไม่ใช่ literal description
✅ Decorative → alt=""
✅ บวกข้อมูล context ที่ visible ผู้ใช้เห็นได้
❌ อย่าใส่ "image of", "picture of"
❌ อย่าทิ้ง alt (browser อ่าน filename = แย่)

6. Forms + Labels

html
<!-- ❌ ไม่มี label -->
<input type="email" placeholder="Email">

<!-- ✅ Label wrap -->
<label>
    Email
    <input type="email" formControlName="email">
</label>

<!-- ✅ Label + for -->
<label for="email">Email</label>
<input id="email" type="email" formControlName="email">

<!-- ✅ Hidden label (visible to AT only) -->
<label for="search" class="visually-hidden">Search</label>
<input id="search" type="search" placeholder="Search...">

<!-- ✅ aria-label (last resort) -->
<input aria-label="Search" type="search" placeholder="Search...">

Visually Hidden Class

scss
/* styles.scss — global */
.visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

→ Screen reader อ่าน, แต่ผู้ใช้ visual ไม่เห็น

Validation Error

📌 ตัวอย่างนี้ใช้ Angular Reactive Forms (formControlName) ซึ่งอธิบายในบท Forms — ถ้ายังไม่รู้จักให้ข้ามตอนนี้แล้วกลับมาหลังอ่านบทนั้น

html
<label for="email">Email</label>
<input 
    id="email" 
    type="email"
    formControlName="email"
    [attr.aria-invalid]="form.controls.email.invalid || null"
    [attr.aria-describedby]="form.controls.email.invalid ? 'email-error' : null"
>
@if (form.controls.email.invalid && form.controls.email.touched) {
    <p id="email-error" class="error" role="alert">
        Invalid email format
    </p>
}

role="alert" → screen reader announce ทันทีเมื่อ error ปรากฏ → aria-describedby → input รู้ว่า error message อยู่ที่ไหน

ทำไมต้อง || null ใน aria-invalid? → ถ้าไม่ invalid จะ "ลบ attribute ทิ้ง" แทนที่จะใส่ "false" (string "false" บางครั้ง screen reader ยังอ่านว่า invalid อยู่ดี) — null บอก Angular ให้ลบ attribute ออกจาก DOM

Required + Optional

html
<!-- ✅ -->
<label for="name">Name <span aria-hidden="true">*</span></label>
<input id="name" required aria-required="true">

<!-- หรือ -->
<label for="name">Name (required)</label>
<input id="name" required>

aria-required="true" ให้ screen reader announce
* แค่ visual — ซ่อนจาก AT ด้วย aria-hidden


7. ARIA — เมื่อ semantic HTML ไม่พอ

📌 First rule of ARIA: อย่าใช้ ARIA ถ้า semantic HTML ทำได้

html
<!-- ❌ ARIA แทน semantic -->
<div role="button" tabindex="0" (click)="submit()" (keydown.enter)="submit()">
    Submit
</div>

<!-- ✅ Just use <button> -->
<button type="button" (click)="submit()">Submit</button>

ARIA = enhance semantic + fill gap

html
<!-- Custom component → ARIA จำเป็น -->
<div 
    role="tablist" 
    aria-label="Account sections"
>
    <button 
        role="tab" 
        [attr.aria-selected]="activeTab() === 'profile'"
        [attr.aria-controls]="'profile-panel'"
        id="profile-tab"
        (click)="setTab('profile')"
    >Profile</button>
    
    <button 
        role="tab" 
        [attr.aria-selected]="activeTab() === 'security'"
        [attr.aria-controls]="'security-panel'"
        id="security-tab"
        (click)="setTab('security')"
    >Security</button>
</div>

<div 
    role="tabpanel" 
    id="profile-panel"
    [attr.aria-labelledby]="'profile-tab'"
    [hidden]="activeTab() !== 'profile'"
>...</div>

ARIA Attributes ที่ใช้บ่อย

Attributeใช้ทำอะไรตัวอย่างใช้ตอนไหน
roleบอก role (button, dialog, tab, alert, ...)element ที่ไม่ใช่ semantic HTML ปกติ
aria-labellabel สำหรับ AT (ทดแทน visible text)ปุ่ม icon-only เช่นปุ่ม ✕ ปิด modal
aria-labelledbyid ของ element ที่เป็น labeldialog ที่มี <h2> เป็นชื่อ
aria-describedbyid ของ descriptioninput ที่มี hint/error อยู่คนละ element
aria-hidden="true"ซ่อนจาก ATicon ที่อยู่ข้างข้อความซ้ำ (เช่น ✓ Success)
aria-expandedtrue/false (collapsible)ปุ่มที่เปิด/ปิด panel เช่น dropdown menu, accordion
aria-controlsid ของ element ที่ควบคุมtab → tabpanel
aria-current"page" / "step" / "true"nav link ของหน้าปัจจุบัน, step ปัจจุบันใน wizard
aria-livepolite / assertive (announce changes)live region ที่อัปเดต เช่น cart count, toast
aria-busytrue (loading state)ปุ่ม Save ขณะ loading
aria-disabledtrue/falseปุ่มที่ disable แต่ยังโฟกัสได้ (ต่างจาก disabled ที่ skip Tab)
aria-invalidtrue/false (form error)input ที่ validation ไม่ผ่าน
aria-requiredtrueinput บังคับกรอก
aria-selectedtrue/false (tab, option)tab ที่ active, item ใน listbox
aria-pressedtoggle button stateปุ่ม toggle เช่น Show/Hide password

Common Roles

role เปรียบเหมือนป้ายติดอยู่บน element ถ้าคุณแปะป้าย role="button" บน <div> ก็เหมือนติดป้าย "ทางออกฉุกเฉิน" ไว้ที่ประตูห้องเก็บของ — คนที่เห็นป้ายจะคาดหวังว่าประตูนั้นใช้งานได้ตามที่ป้ายบอก เช่นเดียวกัน screen reader จะ "ประกาศ" ว่า element นี้เป็น button และคาดว่า Enter จะทำงาน ถ้าคุณไม่ได้ต่อสายให้มัน (คือไม่ได้ผูก keyboard handler) ผู้ใช้กด Enter แล้วไม่มีอะไรเกิดขึ้น → เสียของเปล่า

Role แบ่งเป็น 3 กลุ่มหลัก:

  • Landmarks (หมุดหมายของหน้า) — banner (header), navigation (nav), main (เนื้อหาหลัก), contentinfo (footer) → screen reader ใช้กระโดดข้ามส่วน เหมือนสารบัญแบ่งตอน
  • Widgets (interactive control) — button, tab, dialog, slider, progressbar → บอก AT ว่า element นี้ทำอะไรได้ และ keyboard ควรทำงานอย่างไร
  • Document (โครงสร้างเนื้อหา) — article, heading, list, listitem → บอกว่าเนื้อหาถูกจัดเรียงยังไง

role ที่ไม่ได้อยู่ในสามกลุ่มข้างต้น (เช่น combobox, treeitem, treegrid) เป็น pattern เฉพาะทาง — เวลาต้องใช้จริงให้ดูตัวอย่างที่ WAI-ARIA APG (apg.w3.org) มี recipe ครบทุกแบบ

text
Landmarks: banner, navigation, main, complementary, contentinfo
Widgets:   button, link, menu, menuitem, tab, tablist, tabpanel,
            dialog, alertdialog, alert, status, progressbar, slider,
            combobox, listbox, option, tree, treeitem, grid
Document:  article, region, heading, list, listitem

8. Keyboard Navigation

📌 Focus คือตำแหน่งที่ keyboard กำลังโฟกัสอยู่บนหน้า — กดปุ่ม Tab เพื่อย้าย focus ไปยัง element ถัดไป, Shift+Tab ย้อนกลับ

ทุก interactive element ต้อง:

  1. Focusable ด้วย Tab
  2. ทำงานด้วย Enter/Space
  3. ESC ปิด modal/menu
  4. Arrow keys ใน list/tablist/menu

Tab Order

html
<!-- ✅ Default order = DOM order -->
<button>1</button>
<button>2</button>
<button>3</button>

<!-- ⚠️ tabindex (avoid except 0 / -1) -->
<button tabindex="0">Focusable (default)</button>
<div tabindex="0">Make div focusable</div>          <!-- careful -->
<button tabindex="-1">Not in tab order</button>      <!-- programmatic focus only -->

<!-- ❌ tabindex > 0 — almost never -->
<button tabindex="1">Force first</button>           <!-- creates messy order -->

Keyboard Event Handler

typescript
// หมายเหตุ: ตัวอย่างนี้ไม่มี imports เพราะใช้แค่ template syntax พื้นฐาน (ไม่มี directive อื่น)
// Angular 17+ ถือว่า component เป็น standalone โดย default อยู่แล้ว จึงไม่ต้องใส่ standalone: true ก็ compile ผ่าน
@Component({
    selector: 'app-search',
    template: `
        <input 
            #search 
            type="search"
            (keydown.enter)="onSearch(search.value)"
            (keydown.escape)="clear()"
        >
    `
})
export class SearchComponent {
    onSearch(q: string) { /* ... */ }
    clear() { /* ... */ }
}

Angular supports: keydown.enter, keydown.escape, keydown.arrowdown, keydown.tab, keydown.control.s, ฯลฯ

Custom Widget — Modal

typescript
@Component({
    selector: 'app-modal',
    template: `
        <div 
            role="dialog"
            aria-modal="true"
            [attr.aria-labelledby]="titleId"
            (keydown.escape)="close()"
            #modalEl
        >
            <h2 [id]="titleId">{{ title() }}</h2>
            <ng-content />
            <button (click)="close()">Close</button>
        </div>
    `
})
export class ModalComponent {
    title = input.required<string>();
    closed = output<void>();
    
    // 🔴 อย่าใช้ Math.random() — SSR hydration mismatch (server กับ client ได้คนละค่า)
    // ตัวอย่างนี้ใช้ static counter (ตัวแปรนับที่ใช้ร่วมกันทุก instance ของ class นี้) — เขียนง่าย พอสำหรับแอปทั่วไป
    // 📌 ตรวจสอบเอกสารทางการก่อนใช้: Angular CDK มี helper ชื่อ `_IdGenerator` (ขึ้นต้นด้วย `_` = ยังไม่ใช่ public API ที่รับประกันความเสถียร)
    //    ที่ generate id ให้ unique ทั่วทั้งแอป (ข้าม class ก็ไม่ชนกัน) — ถ้าแอปมี modal/dialog หลาย component มาก ลองดู `_IdGenerator` เป็นทางเลือก
    private static nextId = 0;
    titleId = `modal-title-${++ModalComponent.nextId}`;

    close() {
        this.closed.emit();
    }
}

role="dialog" + aria-modal="true" + aria-labelledby = standard modal

🔴 SSR + unique ID — กับดักคลาสสิค: Math.random() / Date.now() / crypto.randomUUID() ใน field initializer = server กับ client ได้ value คนละค่า → hydration mismatch → Angular warn ใน console + UI flash ทางแก้: ใช้ static counter (เหมือนข้างบน) หรือ @angular/cdk/a11y _IdGenerator ที่จัดการให้

SSR (Server-Side Rendering) = Angular render HTML บน server ก่อนส่งให้ browser; hydration = browser รับ HTML นั้นแล้ว Angular "ปลุก" ให้ interactive — ถ้าคุณสร้างแอปด้วย ng new ปกติ (ไม่ได้เลือก SSR) เรื่องนี้ไม่กระทบคุณ ข้ามไปได้เลย; ถ้าใช้ SSR ให้อ่านบท SSR ก่อนแล้วค่อยกลับมาดูส่วนนี้


9. Focus Management

"Focus" คือตำแหน่งที่ keyboard กำลังโฟกัสอยู่ ผู้ใช้ที่ใช้คีย์บอร์ดหรือ screen reader พึ่งมันอย่างมาก เราจึงต้องจัดการให้ดี เช่น พอเปิดฟอร์มควรโฟกัสช่องแรกให้อัตโนมัติ และพอปิด modal ต้องคืนโฟกัสกลับไปจุดเดิม ไม่ให้ผู้ใช้หลงทาง:

Auto-focus on Mount

📌 afterNextRender = callback ที่รันหลังจาก Angular render DOM เสร็จแล้ว (browser only, SSR-safe) ต่างจาก ngOnInit ที่รันก่อน DOM พร้อม — ใช้เมื่อต้องการ access DOM element จริง ๆ

typescript
import { afterNextRender, viewChild, ElementRef } from '@angular/core';

@Component({
    template: `<input #input>`
})
export class FormComponent {
    input = viewChild<ElementRef<HTMLInputElement>>('input');
    
    constructor() {
        afterNextRender(() => {
            this.input()?.nativeElement.focus();
        });
    }
}

⚠️ afterNextRender only runs in browser (SSR-safe)

Restore Focus After Modal Close

typescript
@Component({...})
export class PageComponent {
    private previousFocus: HTMLElement | null = null;
    
    openModal() {
        this.previousFocus = document.activeElement as HTMLElement;
        this.modalOpen.set(true);
    }
    
    closeModal() {
        this.modalOpen.set(false);
        // ✅ ใช้ setTimeout แทน afterNextRender — afterNextRender ต้องเรียกใน constructor/injection context เท่านั้น
        //    ห้ามเรียกใน method ปกติ (Angular 17+ จะ throw NG0203 error)
        setTimeout(() => {
            this.previousFocus?.focus();
        }, 0);
    }
}

// 💡 ทำไม setTimeout(..., 0) ถึงใช้ได้ทั้งที่รอ 0 วินาที?
// setTimeout(..., 0) ไม่ได้รอ 0 วินาทีจริง ๆ — มันบอก browser ว่า "รันโค้ดนี้หลังจากที่คุณ render DOM รอบนี้เสร็จก่อน"
// เหมือนบอกว่า "รอให้คิวงานตอนนี้เสร็จก่อน แล้วค่อยทำอันนี้ต่อ" — เพราะ Angular ต้องทำลาย modal ออกจาก DOM ก่อน
// เราถึงจะ .focus() ปุ่มที่อยู่ข้างหลัง modal ได้ (ถ้าโฟกัสก่อน DOM อัปเดต จะหา element ไม่เจอหรือ focus ไม่ติด)

Skip to Main Content Link

html
<!-- app.component.html — ก่อน nav -->
<a href="#main" class="skip-link">Skip to main content</a>

<nav>...</nav>

<main id="main" tabindex="-1">
    <router-outlet />
</main>
scss
.skip-link {
    position: absolute;
    top: -40px;       /* hidden by default */
    left: 0;
    background: #000;
    color: white;
    padding: 8px;
    z-index: 100;
    
    &:focus {
        top: 0;        /* show on focus */
    }
}

→ Keyboard user กด Tab แรก = focus ที่ skip link → กด Enter ข้าม nav


10. Angular CDK a11y Module

bash
npm install @angular/cdk
# หมายเหตุ: ถ้าติดตั้ง @angular/material ไปแล้ว CDK มาพร้อมกันโดยอัตโนมัติ ไม่ต้องติดตั้งซ้ำ
typescript
import { A11yModule } from '@angular/cdk/a11y';

@Component({
    imports: [A11yModule],
    // ...
})

LiveAnnouncer — Announce Dynamic Change

typescript
import { LiveAnnouncer } from '@angular/cdk/a11y';

@Component({...})
export class CartComponent {
    private announcer = inject(LiveAnnouncer);
    
    addToCart(item: Item) {
        this.cart.add(item);
        this.announcer.announce(
            `Added ${item.name} to cart. ${this.cart.total()} items total.`,
            'polite'    // หรือ 'assertive' สำหรับสำคัญ
        );
    }
    
    remove(item: Item) {
        this.cart.remove(item.id);
        this.announcer.announce(`Removed ${item.name}`, 'polite');
    }
}

polite = รอ assistive technology (AT เช่น screen reader) พักก่อน announce
assertive = แทรกทันที (ใช้สำหรับ error สำคัญ)

FocusTrap — Trap Tab in Modal

ลองกด Tab ใน modal ที่ไม่มี FocusTrap — Tab จะหลุดไปยังปุ่มด้านหลัง modal (ที่ผู้ใช้มองไม่เห็น) keyboard user จะ "หลง" อยู่ใน page ส่วนที่ถูก modal บัง กด Enter โดยไม่รู้ว่าไปโดน element อะไรอยู่ FocusTrap แก้ปัญหานี้ด้วยการ "ขัง" Tab ให้วนอยู่แค่ภายใน modal เท่านั้น

💡 cdkTrapFocus vs cdkTrapFocusAutoCapture:

  • cdkTrapFocus = ขัง Tab ไว้ใน div นั้น — แต่โฟกัสเริ่มต้นจะยังอยู่ที่เดิม ผู้ใช้ต้อง Tab เองจนเข้ามาใน modal
  • [cdkTrapFocusAutoCapture]="true" = พอเปิด modal ให้โฟกัสที่ focusable element แรกทันทีโดยอัตโนมัติ — ไม่ต้องรอให้ผู้ใช้ Tab เอง
typescript
import { ConfigurableFocusTrapFactory } from '@angular/cdk/a11y';

@Component({
    template: `
        <div cdkTrapFocus [cdkTrapFocusAutoCapture]="true">
            <h2>Modal</h2>
            <input>
            <button>OK</button>
            <button (click)="close()">Cancel</button>
        </div>
    `,
    imports: [A11yModule]
})
export class ModalComponent {
    // Tab ภายใน modal วน ไม่หลุดออก
    // [cdkTrapFocusAutoCapture] = true → auto focus first focusable
}

FocusMonitor — Detect Focus Source

💡 RxJS ใน FocusMonitor — อ่านก่อนดูโค้ด

FocusMonitor ส่ง "stream" ของเหตุการณ์ — คิดเหมือนสายพานในโรงงานที่ส่งค่า 'keyboard', 'mouse', 'touch' หรือ null ทุกครั้งที่ focus เปลี่ยน คำสำคัญที่จะเห็นใน code:

  • .pipe(...) = ท่อส่งข้อมูลต่อ (กรอง/แปลง ก่อนส่งให้ผู้รับ)
  • .subscribe(fn) = ตั้งคนรับปลายสายพาน (ทุกครั้งที่มีค่าใหม่ fn จะถูกเรียก)
  • takeUntilDestroyed = ตัดสายพานเมื่อ component ถูกทำลาย เพื่อไม่ให้หน่วยความจำรั่ว
  • DestroyRef = reference ที่ Angular ให้ไว้เพื่อ "รู้" ว่า component ถูก destroy เมื่อไหร่

RxJS อธิบายเต็มในบท Reactive Programming — ตอนนี้อ่าน comment ในโค้ดข้างล่างเพื่อเข้าใจ flow เบื้องต้นก่อนได้เลย

📌 subscription leak = เมื่อ component ถูกทำลายแต่ยังมีโค้ด RxJS ค้างรันอยู่ในหน่วยความจำ — takeUntilDestroyed จะ unsubscribe อัตโนมัติเมื่อ component ถูก destroy ป้องกัน memory leak

typescript
import { FocusMonitor } from '@angular/cdk/a11y';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

@Component({...})
export class CustomButtonComponent {
    private focusMonitor = inject(FocusMonitor);
    private el = inject(ElementRef);
    private destroyRef = inject(DestroyRef);

    ngAfterViewInit() {
        // ✅ takeUntilDestroyed(this.destroyRef) — inject DestroyRef ที่ระดับ field แล้วส่งเข้า method ได้
        //    (ต้อง inject DestroyRef ที่ field ก่อน ไม่ใช่สร้างใน method เพราะ inject ต้องอยู่ใน injection context)
        this.focusMonitor.monitor(this.el)
            .pipe(takeUntilDestroyed(this.destroyRef))    // ✅ auto-cleanup ป้องกัน leak
            .subscribe(origin => {
                // origin = 'mouse' | 'keyboard' | 'touch' | 'program' | null
                console.log('focused from:', origin);
            });
    }

    ngOnDestroy() {
        this.focusMonitor.stopMonitoring(this.el);
    }
}

📌 ต้องทำสองชั้น (คนละ layer):

  • takeUntilDestroyed = ปิด RxJS subscription เมื่อ component destroy
  • stopMonitoring = unregister FocusMonitor event listener (คืน resource ของ CDK)

ขาดอย่างใดอย่างหนึ่ง memory leak ได้

→ เพิ่ม CSS class .cdk-focused และ .cdk-keyboard-focused อัตโนมัติ — ใช้สร้าง focus ring (กรอบแสดงว่า element กำลัง focus อยู่) ที่ดูดีขึ้น

scss
/* ✅ fallback: ถ้า element ไม่ได้ถูก monitor ด้วย FocusMonitor ก็ยังมี focus indicator */
.btn:focus-visible {
    outline: 2px solid #1976d2;
    outline-offset: 2px;
}

/* ✅ เมื่อ FocusMonitor ทำงาน — ซ่อน browser default แล้วใช้ CDK class แทน */
.btn {
    outline: none;
}

/* FocusMonitor เพิ่ม .cdk-focused และ .cdk-keyboard-focused พร้อมกัน */
.btn.cdk-focused.cdk-keyboard-focused {
    outline: 2px solid #1976d2;
    outline-offset: 2px;
}

→ Outline ring แสดงเฉพาะ keyboard navigation (ไม่ใช่ mouse click)

cdkAriaLive Directive

html
<!-- Announce changes inside -->
<div cdkAriaLive="polite">
    <p>Items in cart: {{ cart.count() }}</p>
</div>

→ Change → auto announce (ไม่ต้องเรียก LiveAnnouncer manual)


11. Color Contrast

ผู้ใช้ที่สายตาเลือนรางหรือตาบอดสีจะอ่านตัวอักษรไม่ออกถ้าสีตัวอักษรกับพื้นหลังต่างกันน้อยเกินไป มาตรฐาน WCAG จึงกำหนด "อัตราส่วนความต่างของสี" (contrast ratio) ขั้นต่ำไว้ มาดูเกณฑ์และตัวอย่างผ่าน/ไม่ผ่าน พร้อมเครื่องมือวัด:

text
WCAG AA:
- Normal text  ≥ 4.5:1
- Large text   ≥ 3:1  (18pt+ regular หรือ 14pt+ bold)
- UI components ≥ 3:1 (border, icon)

WCAG AAA:
- Normal text  ≥ 7:1
- Large text   ≥ 4.5:1

❌ Bad

scss
/* gray on white = ~3.5:1 — FAIL */
.text { color: #aaa; background: #fff; }

✅ Good

scss
.text { color: #555; background: #fff; }    /* ~7.5:1 */
.muted { color: #767676; background: #fff; }  /* ~4.5:1 — minimum */

Tools

text
Browser:
- Chrome DevTools → Lighthouse / Inspect element → Contrast ratio shown
- Firefox DevTools → Accessibility Inspector

VS Code:
- "axe Accessibility Linter" extension

Standalone:
- WebAIM Contrast Checker (online)
- Stark (Figma plugin)
- Contrast app (macOS)

Don't Rely on Color Alone

html
<!-- ❌ Only color -->
<span class="green">Success</span>
<span class="red">Error</span>

<!-- ✅ Color + icon + text -->
<span class="success">
    <svg aria-hidden="true">...</svg> ✓ Success
</span>
<span class="error" role="alert">
    <svg aria-hidden="true">...</svg> ✗ Error: {{ msg }}
</span>

12. Loading + Error State

สถานะกำลังโหลดหรือ error ที่แสดงด้วยภาพอย่างเดียว ผู้ใช้ screen reader จะไม่รู้เลยว่าเกิดอะไรขึ้น เราต้องใช้ ARIA live region (aria-live, role="status", role="alert") เพื่อให้ screen reader "ประกาศ" การเปลี่ยนแปลงเหล่านี้ออกเสียงโดยอัตโนมัติ:

html
<!-- Loading -->
<div role="status" aria-live="polite">
    @if (loading()) {
        <span class="visually-hidden">Loading users...</span>
        <app-spinner aria-hidden="true" />
    } @else {
        <p>{{ users().length }} users loaded</p>
    }
</div>

<!-- Error -->
@if (error()) {
    <div role="alert" class="error">
        {{ error()?.message }}
    </div>
}

<!-- Form submit -->
<button 
    type="submit" 
    [disabled]="loading()"
    [attr.aria-busy]="loading()"
>
    {{ loading() ? 'Saving...' : 'Save' }}
</button>

13. Tables

html
<!-- ✅ -->
<table>
    <caption>Q1 2026 Sales</caption>
    <thead>
        <tr>
            <th scope="col">Product</th>
            <th scope="col">Quantity</th>
            <th scope="col">Revenue</th>
        </tr>
    </thead>
    <tbody>
        @for (row of rows(); track row.id) {
            <tr>
                <th scope="row">{{ row.product }}</th>
                <td>{{ row.qty }}</td>
                <td>{{ row.revenue | currency }}</td>
            </tr>
        }
    </tbody>
</table>
  • <caption> = ชื่อตาราง (screen reader อ่านก่อน)
  • scope="col" = บอกว่า header นี้คุม column
  • scope="row" = แถวแรกเป็น header ของ row

Data Grid (Complex)

ถ้า table มี sort/filter/edit → ใช้ role="grid" (role="grid" = ตารางที่ interactive — มีการ sort/edit ได้ ต่างจาก table ธรรมดาที่แค่แสดงข้อมูล) + Angular CDK Table:

bash
# Angular Material — provides accessible table
ng add @angular/material
typescript
import { MatTableModule } from '@angular/material/table';

// MatTable มี a11y built-in

14. Modal/Dialog (Full Example)

Modal เป็นหนึ่งใน component ที่ทำให้ accessible ยากที่สุด เพราะต้อง "ขัง" โฟกัสไว้ข้างใน (focus trap), ปิดด้วยปุ่ม Esc, คืนโฟกัสเมื่อปิด และบอก screen reader ว่านี่คือ dialog ตัวอย่างเต็มนี้ใช้ FocusTrap ของ Angular CDK จัดการให้ครบ:

❌ Broken pattern — capture activeElement ใน constructor

ตัวอย่างที่ "ดูเหมือนจะใช้ได้" แต่จริง ๆ มีบั๊ก:

typescript
// ❌ ห้ามทำแบบนี้
export class BrokenDialogComponent {
    private previousFocus: HTMLElement | null = null;

    constructor() {
        // 🔴 บั๊ก: เวลา constructor ทำงาน Angular อาจกำลังประกอบ DOM อยู่
        //    document.activeElement อาจไม่ใช่ปุ่มที่กดเปิด dialog แล้ว
        //    บางเคส = body, บางเคส = element ใน dialog เอง → restore ผิด
        this.previousFocus = document.activeElement as HTMLElement;
    }
}

ทำไมพัง: เมื่อ Angular สร้าง dialog component, ลำดับการ focus ไม่แน่นอน — ขึ้นกับว่าใช้ *ngIf / @if / dynamic ComponentRef / overlay หรืออะไร ผลคือ document.activeElement ตอน constructor อาจไม่ใช่ trigger element ที่เราต้องการคืนโฟกัสกลับไป

✅ Fixed pattern — parent capture + ส่งเข้าทาง input

ให้ parent เก็บ document.activeElement ก่อน เปิด dialog แล้วส่งเป็น input ให้ dialog เก็บไว้คืนภายหลัง:

typescript
import { Component, output, input } from '@angular/core';
import { A11yModule } from '@angular/cdk/a11y';

@Component({
    selector: 'app-dialog',
    standalone: true,
    imports: [A11yModule],
    template: `
        <div
            class="backdrop"
            (click)="onBackdropClick()"
            (keydown.escape)="close()"
        >
            <div
                class="dialog"
                role="dialog"
                aria-modal="true"
                [attr.aria-labelledby]="titleId"
                [attr.aria-describedby]="descId"
                cdkTrapFocus
                cdkTrapFocusAutoCapture
                (click)="$event.stopPropagation()"
            >
                <h2 [id]="titleId">{{ title() }}</h2>
                <p [id]="descId">{{ description() }}</p>

                <ng-content />

                <div class="actions">
                    <button (click)="confirm()">{{ confirmText() }}</button>
                    <button (click)="close()">{{ cancelText() }}</button>
                </div>
            </div>
        </div>
    `,
    styles: `
        .backdrop {
            position: fixed; inset: 0;
            background: rgba(0,0,0,0.5);
            display: flex; align-items: center; justify-content: center;
            z-index: 1000;
        }
        .dialog {
            background: white; padding: 24px;
            border-radius: 8px; max-width: 500px; width: 90vw;
        }
        .actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
    `
})
export class DialogComponent {
    title = input.required<string>();
    description = input('');
    confirmText = input('OK');
    cancelText = input('Cancel');

    // ✅ parent ส่ง trigger element มาให้ (จับก่อนเปิด dialog)
    triggerElement = input<HTMLElement | null>(null);

    confirmed = output<void>();
    closed = output<void>();

    // ✅ ใช้ counter — SSR safe (production แนะนำ _IdGenerator จาก @angular/cdk/a11y)
    // ⚠️ ลำดับสำคัญ: idCounter ต้องประกาศไว้ "ก่อน" id เสมอ — field ใน class รันตามลำดับบนลงล่าง
    private static idCounter = 0;
    private id = ++DialogComponent.idCounter;
    titleId = `dialog-title-${this.id}`;
    descId = `dialog-desc-${this.id}`;

    confirm() {
        this.confirmed.emit();
        this.restore();
    }

    close() {
        this.closed.emit();
        this.restore();
    }

    onBackdropClick() { this.close(); }

    private restore() {
        // คืนโฟกัสไปที่ trigger element ที่ parent ส่งมา
        setTimeout(() => this.triggerElement()?.focus(), 0);
    }
}

ฝั่ง parent:

typescript
@Component({
    template: `
        <button #openBtn (click)="open(openBtn)">Open dialog</button>

        @if (showDialog()) {
            <app-dialog
                title="Confirm"
                [triggerElement]="trigger()"
                (closed)="showDialog.set(false)"
            />
        }
    `
})
export class PageComponent {
    showDialog = signal(false);
    trigger = signal<HTMLElement | null>(null);

    open(btn: HTMLElement) {
        // ✅ จับ trigger ก่อน เปิด dialog — ตอนนี้ activeElement ยังเป็น btn ชัวร์
        this.trigger.set(btn);
        this.showDialog.set(true);
    }
}

→ ครบ: role="dialog", focus trap, ESC ปิด, restore focus (ถูกต้อง), backdrop click

⚠️ triggerElement ต้องเป็น element จริงเสมอ: ตัวอย่างข้างบนสมมติว่า dialog เปิดจากการคลิกปุ่มเสมอ ถ้าแอปของคุณเปิด dialog ด้วยวิธีอื่น (เช่น keyboard shortcut หรือ navigate มาแล้วเปิดอัตโนมัติ) โดยไม่มี element ให้จับ triggerElement จะเป็น null และ restore() จะไม่ทำอะไรเลย (เงียบ ไม่มี error) — ผู้เรียกต้องส่ง document.activeElement หรือ element อื่นที่เหมาะสมเข้ามาเองในกรณีนั้น

💡 ทางลัด: ถ้าไม่อยากเขียนเอง ใช้ @angular/cdk/dialog หรือ MatDialog — Angular CDK/Material จัดการ trigger element capture + restore focus + focus trap + ARIA ให้ทั้งหมด


15. Routing + Page Title + Focus

🎯 ปัญหาที่ต้องแก้

ใน SPA (Single Page App) เมื่อคลิก link เพื่อไปหน้าอื่น browser ไม่ได้โหลดหน้าใหม่จริง ๆ — Angular แค่สลับ component ใน <router-outlet> screen reader จึงไม่รู้ว่า "เปลี่ยนหน้า" แล้ว เหมือนเดินเข้าห้องใหม่แต่ไม่มีใครบอกว่าอยู่ห้องอะไร ผู้ใช้ blind จะยังได้ยิน content ของหน้าเดิมค้างอยู่ในหัว

แก้ได้สองระดับ:

(1) วิธีง่าย — ประกาศตรง ๆ ใน component (ไม่ต้องใช้ service หรือ RxJS):

typescript
// ใน component ที่รับ navigate มาถึง — เรียกใน constructor
export class DashboardComponent {
    private announcer = inject(LiveAnnouncer);
    constructor() {
        afterNextRender(() => {
            this.announcer.announce('Navigated to Dashboard', 'polite');
        });
    }
}

ใช้วิธีนี้ก่อนถ้าแอปยังเล็ก หรือยังไม่คุ้น service/RxJS

(2) วิธี reusable — ย้ายเป็น service (code ถัดไป) จัดการ announce ทุกหน้าจากจุดเดียว ไม่ต้องเขียนซ้ำใน component service และ RxJS อธิบายละเอียดในบทถัดไป — ดู code นี้เพื่อเข้าใจ pattern ก็พอ

ในแอป SPA การเปลี่ยนหน้าไม่ได้โหลดใหม่ทั้งหน้า screen reader จึงอาจไม่รู้ว่าเปลี่ยนหน้าแล้ว เราต้องช่วยมันสองอย่าง: อัปเดต title ของหน้าให้ถูกต้อง และย้ายโฟกัสไปต้นเนื้อหาใหม่ทุกครั้งที่นำทาง:

typescript
// app.config.ts
import { provideRouter, withRouterConfig } from '@angular/router';

provideRouter(routes,
    // ⚠️ onSameUrlNavigation: 'reload' เป็น optional config แยกต่างหาก
    //    ไม่เกี่ยวกับ a11y announcement โดยตรง — ทำให้ navigate ไปหน้าเดิมซ้ำแล้ว reload
    //    (อาจมี side effect เช่น form reset, data re-fetch) — เพิ่มเฉพาะถ้าต้องการ
    withRouterConfig({ onSameUrlNavigation: 'reload' })
)

Announce Page Change

typescript
import { Router, NavigationEnd } from '@angular/router';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { Title } from '@angular/platform-browser';
import { Injectable, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class A11yRoutingService {
    private router = inject(Router);
    private title = inject(Title);
    private announcer = inject(LiveAnnouncer);
    
    private destroyRef = inject(DestroyRef);

    constructor() {
        this.router.events.pipe(
            // e is NavigationEnd = type guard บอก TypeScript ว่า e เป็นประเภทนี้แน่ (ดู README TS Recap)
            filter((e): e is NavigationEnd => e instanceof NavigationEnd),
            takeUntilDestroyed(this.destroyRef)    // ✅ auto-cleanup (best practice แม้เป็น root service)
        ).subscribe(() => {
            // หลัง title update
            setTimeout(() => {
                this.announcer.announce(`Navigated to ${this.title.getTitle()}`, 'polite');
                
                // Focus main content
                const main = document.getElementById('main-content');
                main?.focus();
            }, 100);
        });
    }
}
typescript
// main.ts หรือ app.component.ts — init
inject(A11yRoutingService);

16. Testing a11y

Automated — Lighthouse

bash
# Chrome DevTools → Lighthouse → Accessibility audit
# จับได้ ~30% ของปัญหา

Automated — axe-core

bash
npm install --save-dev @axe-core/playwright
typescript
// playwright e2e
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test('page should have no a11y violations', async ({ page }) => {
    await page.goto('/');
    
    const results = await new AxeBuilder({ page }).analyze();
    expect(results.violations).toEqual([]);
});

test('form should be accessible', async ({ page }) => {
    await page.goto('/signup');
    
    const results = await new AxeBuilder({ page })
        .withTags(['wcag2a', 'wcag2aa'])
        .analyze();
    
    expect(results.violations).toEqual([]);
});

Unit Test — TestBed

TestBed คือสนามทดสอบจำลองของ Angular — มันสร้าง component ขึ้นในหน่วยความจำโดยไม่ต้องเปิด browser จริง ทำให้ test รันเร็วและรันได้ใน CI fixture.nativeElement คือ DOM element จริงของ component นั้น ซึ่ง axe จะ scan หา a11y violation ทั้งหมด TestBed อธิบายเพิ่มเติมในบท Testing — ตอนนี้รู้แค่นี้ก็รัน test แรกได้แล้ว

📌 axe-core กับ @axe-core/playwright เป็น package คนละตัว — axe-core ใช้ใน unit test (TestBed/JSDOM), @axe-core/playwright ใช้ใน E2E test — ต้องติดตั้งแยกกัน

⚠️ ข้อจำกัด: axe ใน JSDOM (environment ของ Angular TestBed) ไม่สามารถตรวจ contrast ratio หรือ layout-based violations ได้เพราะ JSDOM ไม่มี CSS layout จริง — สำหรับ audit ครบถ้วนให้ใช้ @axe-core/playwright (E2E) แทน

bash
npm install --save-dev axe-core
typescript
import axe from 'axe-core';

it('should have no a11y violation', async () => {
    const fixture = TestBed.createComponent(LoginComponent);
    fixture.detectChanges();
    
    // axe = default import (ไม่ใช่ named import { axe })
    const results = await axe(fixture.nativeElement);
    expect(results.violations).toEqual([]);
});

Manual — Screen Reader

text
🪟 Windows:
- NVDA (free) — https://nvaccess.org
- JAWS (paid, enterprise)
- Narrator (built-in Win 10+)

🍎 macOS:
- VoiceOver (built-in, Cmd+F5)

📱 iOS: VoiceOver
🤖 Android: TalkBack

NVDA Keyboard Test (Windows)

text
Insert + Down  — read all (อ่านทั้งหน้า)
Tab            — next focusable (ไปยัง element ถัดไปที่ focus ได้)
H              — next heading (ข้ามไป heading ถัดไป)
F              — next form field (ข้ามไป form field ถัดไป)
B              — next button (ข้ามไป button ถัดไป)
1-6            — next h1-h6 (ข้ามไป heading ระดับนั้น ๆ)
Insert + F7    — element list (แสดงรายการ links, headings, landmarks ทั้งหน้า)

→ ทดสอบจริง — ปิดตาเองแล้วลองใช้ app

Keyboard-Only Test

text
1. ไม่จับเมาส์
2. Tab → ไปทั่ว app
3. Enter/Space → ทำงาน
4. ESC → ปิด modal/menu
5. ทุกอย่างต้องทำได้

17. prefers-reduced-motion + WCAG 2.2 ใหม่ที่ต้องรู้

prefers-reduced-motion — WCAG 2.3.3 (Animation from Interactions)

scss
// ปิด animation ถ้า user ตั้งค่า OS ลด motion
@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        animation-duration: 0.01ms !important;
        transition-duration: 0.01ms !important;
        scroll-behavior: auto !important;
    }
}
typescript
// ใน TS — เช็คก่อนเรียก animation API
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!prefersReducedMotion) {
    element.animate(keyframes, options);
}

WCAG 2.2 criteria ใหม่ (ตุลาคม 2023) — บังคับแล้วใน EAA มิ.ย. 2025

Criterionสิ่งที่ต้องทำ
2.4.11 Focus Not Obscured (AA)focus indicator ห้ามถูกบัง sticky header/footer ทั้งหมด — scroll-padding-top ช่วยได้
2.5.7 Dragging Movements (AA)drag-drop ต้องมี alternative ที่ใช้ click/keyboard ได้
2.5.8 Target Size (AA)clickable target ≥ 24×24px minimum (WCAG 2.2 AA) — Apple HIG / Google Material แนะนำ 44×44px เป็น best practice สำหรับ mobile
3.3.7 Redundant Entry (A)อย่าให้ user ใส่ข้อมูลซ้ำใน flow เดียว — auto-fill หรือ "เหมือนที่อยู่จัดส่ง" checkbox
3.3.8 Accessible Authentication (AA)ห้ามบังคับ cognitive test เป็นทางเดียวในการ authenticate — ต้องมีทางเลือกที่ไม่ต้องจำ เช่น: password manager, biometric (ลายนิ้วมือ/ใบหน้า), passkey (วิธี login รุ่นใหม่ไม่ต้องใช้ password)

📌 ศัพท์ในตาราง:

  • scroll-padding-top (2.4.11) = CSS property ที่กันพื้นที่บนสุดของ scroll container ไว้ — ใช้กับ sticky header เพื่อให้ focus ไม่ถูกบัง
  • cognitive test = อะไรก็ตามที่บังคับให้สมองคิด/จำ เช่น CAPTCHA จำตัวอักษร, แก้ puzzle, จำ password ยาว — ห้ามเป็นทางเดียวในการ authenticate ต้องมีทางเลือกที่ไม่ใช้ความจำ
  • passkey = วิธี login รุ่นใหม่ที่ใช้ลายนิ้วมือ/ใบหน้า/PIN แทน password — ปลอดภัยและ accessible กว่า

EU Accessibility Act (EAA — กฎหมาย accessibility ของสหภาพยุโรป) — บังคับแล้วตั้งแต่ 28 มิ.ย. 2025

ครอบคลุม banking, e-commerce, transport, telecom ที่ทำ B2C ใน EU แม้บริษัทอยู่ไทย — ถ้ามีลูกค้า EU ต้องผ่าน WCAG 2.1/2.2 AA

18. ⚠️ Common Mistakes

เหตุผล
<div onclick><button (click)>div ไม่ focusable, ไม่ keyboard
placeholder แทน label<label> + placeholder ช่วยplaceholder หายเมื่อพิมพ์
tabindex="5"tabindex="0" หรือไม่ใส่ลำดับ Tab สับสน
<img> ไม่มี altalt="..." หรือ alt=""screen reader อ่าน filename
Modal ไม่มี focus trapใช้ cdkTrapFocusTab หลุดออก modal
ESC ไม่ปิด modal(keydown.escape)UX มาตรฐาน
Form error ไม่มี role="alert"role="alert"AT ไม่ announce
Color-only signalcolor + icon + textcolorblind ไม่เห็น
Auto-play mediaให้ผู้ใช้เป็นคนกดเล่นเอง (user-initiated)ผู้ที่ใช้ screen reader จะตกใจและรำคาญเมื่อมีเสียงเล่นทันทีที่เปิดหน้า
Carousel auto-advancepause controlผู้ใช้อ่านช้าไม่ทัน
Skip to main ไม่มีเพิ่ม skip linkkeyboard user เสียเวลา
Focus indicator ซ่อนoutline: none ผิด — ใช้ FocusMonitor + .cdk-keyboard-focused (ดู section 10)keyboard user หลง

19. Accessibility Checklist (Production)

ก่อนปล่อยแอป ใช้เช็คลิสต์นี้ไล่กาทีละข้อ เพื่อให้มั่นใจว่าครอบคลุมเรื่อง a11y สำคัญทั้งหมด — ตั้งแต่ semantic HTML, alt, label, contrast, การใช้งานด้วยคีย์บอร์ด ไปจนถึงการทดสอบจริงกับ screen reader:

text
□ Semantic HTML (header, nav, main, footer, h1-h6)
□ ทุก image มี alt (informative หรือ "")
□ ทุก input มี label (visible หรือ visually-hidden)
□ Form error มี role="alert" + aria-describedby
□ Color contrast ≥ 4.5:1 (text), 3:1 (UI)
□ ทำงานได้ด้วย keyboard เท่านั้น
□ Focus indicator มองเห็นได้
□ Skip to main link
□ Heading hierarchy (h1 → h2 → h3 ไม่ skip)
□ Modal: role="dialog", focus trap, ESC, restore focus
□ Tab/menu/dropdown ทำตาม ARIA pattern
□ Loading state announce ผ่าน aria-live
□ Page title update เมื่อ navigate
□ Routing: focus main content after nav
□ Lighthouse a11y score ≥ 95
□ axe-core ใน CI — 0 violations
□ ทดสอบจริงกับ screen reader (NVDA/VoiceOver) อย่างน้อย 1 รอบ
□ ทดสอบ keyboard-only ทุก feature
□ Touch target ≥ 44×44px (mobile — best practice) / ≥ 24×24px (WCAG 2.2 AA minimum)
□ ไม่ใช้ color เป็น signal เดียว
□ ไม่ auto-play media with sound

20. ARIA Patterns — Reference

สำหรับ widget ซับซ้อน (accordion, combobox, tabs ฯลฯ) ไม่ต้องคิดเองว่าต้องใช้ ARIA และ keyboard อย่างไร — W3C มี "recipe" มาตรฐานให้ครบทุกแบบ ตารางนี้รวมรายชื่อ pattern ที่พบบ่อยพร้อมลิงก์ไปดูรายละเอียด:

ดูที่ w3.org/WAI/ARIA/apg/patterns — มี recipe ของทุก widget:

text
Accordion         — disclosure / aria-expanded
Alert             — role="alert"
Breadcrumb        — nav + aria-current="page"
Button            — role="button" or <button>
Carousel          — region + tabs + controls
Checkbox          — role="checkbox" (or <input type="checkbox">)
Combobox          — autocomplete pattern
Dialog (Modal)    — role="dialog" + aria-modal
Disclosure        — aria-expanded
Feed              — infinite list
Grid              — data grid
Link              — <a> with role="link"
Listbox           — single/multi select
Menu              — application menu
Menubar           — top-level menu
Meter             — gauge
Radio Group       — fieldset + radio
Slider            — single value (vol, brightness)
Spinbutton        — number input with up/down
Switch            — toggle (on/off)
Table             — <table> with caption + scope
Tabs              — tablist + tab + tabpanel
Toolbar           — collection of buttons
Tooltip           — hover/focus reveal
Tree              — hierarchical list
Treegrid          — tree + grid

แต่ละ pattern บอก: keyboard interaction, ARIA roles/states/properties


21. Resources

แหล่งเรียนรู้ต่อด้าน accessibility ที่แนะนำ — ทั้งเอกสารมาตรฐาน (WCAG, ARIA), เครื่องมือตรวจสอบ และคอร์สเรียนฟรี:

Official

Tools

  • axe DevTools — Chrome / Firefox extension
  • WAVE — visual a11y indicator
  • Lighthouse — built-in Chrome
  • Pa11yCLI audit
  • axe-core — programmatic testing
  • storybook-addon-a11y — Storybook integration

Learning

Community

  • WebAIM mailing list
  • /r/accessibility
  • Slack: web-a11y

22. ตัวอย่างเต็ม — Accessible Login Form

ปิดท้ายด้วยตัวอย่างฟอร์ม login ที่ประกอบทุกหลักการในบทนี้เข้าด้วยกัน — มี label ครบ, error ที่ screen reader ประกาศได้, จัดการโฟกัส และใช้งานด้วยคีย์บอร์ดได้สมบูรณ์ ใช้เป็นแม่แบบฟอร์มที่ accessible ได้เลย:

typescript
import { Component, inject, signal, viewChild, ElementRef, afterNextRender } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { LiveAnnouncer, A11yModule } from '@angular/cdk/a11y';
import { Router, RouterLink } from '@angular/router';

@Component({
    selector: 'app-login',
    standalone: true,
    imports: [ReactiveFormsModule, A11yModule, RouterLink],
    template: `
        <main id="main-content" tabindex="-1">
            <h1>Sign In</h1>
            
            <form 
                [formGroup]="form" 
                (ngSubmit)="onSubmit()" 
                novalidate
                aria-labelledby="form-title"
            >
                <h2 id="form-title" class="visually-hidden">Login form</h2>
                
                <!-- Email -->
                <div class="field">
                    <label for="email">
                        Email <span aria-hidden="true">*</span>
                    </label>
                    <input 
                        id="email"
                        type="email"
                        formControlName="email"
                        autocomplete="username"
                        required
                        aria-required="true"
                        [attr.aria-invalid]="isInvalid('email')"
                        [attr.aria-describedby]="
                            (isInvalid('email') ? 'email-error ' : '') + 'email-hint'
                        "
                        #emailInput
                    >
                    <p id="email-hint" class="hint">
                        Use your work email
                    </p>
                    @if (isInvalid('email')) {
                        <p id="email-error" class="error" role="alert">
                            {{ getError('email') }}
                        </p>
                    }
                </div>
                
                <!-- Password -->
                <div class="field">
                    <label for="password">
                        Password <span aria-hidden="true">*</span>
                    </label>
                    <input 
                        id="password"
                        [type]="showPassword() ? 'text' : 'password'"
                        formControlName="password"
                        autocomplete="current-password"
                        required
                        aria-required="true"
                        [attr.aria-invalid]="isInvalid('password')"
                        [attr.aria-describedby]="
                            isInvalid('password') ? 'password-error' : null
                        "
                    >
                    <button 
                        type="button" 
                        (click)="togglePassword()"
                        [attr.aria-label]="
                            showPassword() ? 'Hide password' : 'Show password'
                        "
                        [attr.aria-pressed]="showPassword()"
                    >
                        {{ showPassword() ? '🙈' : '👁️' }}
                    </button>
                    @if (isInvalid('password')) {
                        <p id="password-error" class="error" role="alert">
                            {{ getError('password') }}
                        </p>
                    }
                </div>
                
                <!-- Remember me -->
                <div class="field">
                    <label>
                        <input type="checkbox" formControlName="remember">
                        Remember me for 30 days <!-- ในแอปจริงใช้ภาษาของผู้ใช้ (i18n) — ตัวอย่างนี้ใช้อังกฤษเพื่อแสดง pattern เท่านั้น -->
                    </label>
                </div>
                
                <!-- Submit -->
                <div class="actions">
                    <button 
                        type="submit"
                        [disabled]="loading()"
                        [attr.aria-busy]="loading()"
                    >
                        {{ loading() ? 'Signing in...' : 'Sign In' }}
                    </button>
                </div>
                
                <!-- Form-level error -->
                @if (formError()) {
                    <div role="alert" class="form-error">
                        {{ formError() }}
                    </div>
                }
            </form>
            
            <p>
                Don't have an account? 
                <a routerLink="/register">Register</a>
            </p>
        </main>
    `,
    styles: `
        main { max-width: 400px; margin: 2rem auto; padding: 1rem; }
        .field { margin-bottom: 1rem; }
        label { display: block; font-weight: 500; margin-bottom: 4px; }
        input[type="email"], input[type="password"], input[type="text"] {
            width: 100%; padding: 8px;
            border: 1px solid #555; border-radius: 4px;
        }
        input[aria-invalid="true"] { border-color: #c53030; }
        .hint { color: #555; font-size: 14px; margin: 4px 0; }
        .error { color: #c53030; font-size: 14px; margin: 4px 0; }
        .form-error { 
            color: #c53030; padding: 12px; 
            border: 1px solid #c53030; border-radius: 4px;
            margin-top: 1rem;
        }
        button {
            padding: 12px 24px; min-height: 44px;     /* touch target */
            background: #1976d2; color: white; border: none;
            border-radius: 4px; cursor: pointer;
        }
        button:focus-visible {
            outline: 2px solid #1976d2;
            outline-offset: 2px;
        }
        button:disabled { opacity: 0.5; cursor: not-allowed; }
        
        .visually-hidden {
            position: absolute; width: 1px; height: 1px;
            padding: 0; margin: -1px; overflow: hidden;
            clip: rect(0,0,0,0); white-space: nowrap; border: 0;
        }
    `
})
export class LoginComponent {
    private fb = inject(FormBuilder);
    private router = inject(Router);
    private announcer = inject(LiveAnnouncer);
    
    emailInput = viewChild<ElementRef<HTMLInputElement>>('emailInput');
    
    form = this.fb.nonNullable.group({
        email: ['', [Validators.required, Validators.email]],
        password: ['', [Validators.required, Validators.minLength(8)]],
        remember: [false]
    });
    
    loading = signal(false);
    formError = signal<string | null>(null);
    showPassword = signal(false);
    
    constructor() {
        afterNextRender(() => {
            this.emailInput()?.nativeElement.focus();
            this.announcer.announce('Login page loaded', 'polite');
        });
    }
    
    isInvalid(field: string): boolean {
        const ctrl = this.form.get(field);
        return !!(ctrl?.invalid && ctrl?.touched);
    }
    
    getError(field: string): string {
        const errors = this.form.get(field)?.errors;
        if (!errors) return '';
        if (errors['required']) return 'This field is required';
        if (errors['email']) return 'Please enter a valid email address';
        if (errors['minlength']) {
            return `Must be at least ${errors['minlength'].requiredLength} characters`;
        }
        return 'Invalid value';
    }
    
    togglePassword() {
        this.showPassword.update(v => !v);
        this.announcer.announce(
            this.showPassword() ? 'Password visible' : 'Password hidden',
            'polite'
        );
    }
    
    async onSubmit() {
        this.form.markAllAsTouched();
        if (this.form.invalid) {
            this.announcer.announce('Form has errors. Please correct them.', 'assertive');
            // Focus first invalid field
            const firstInvalid = Object.entries(this.form.controls)
                .find(([_, ctrl]) => ctrl.invalid);
            if (firstInvalid) {
                document.getElementById(firstInvalid[0])?.focus();
            }
            return;
        }
        
        this.loading.set(true);
        this.formError.set(null);
        
        try {
            // await this.authService.login(this.form.getRawValue());
            this.announcer.announce('Login successful. Redirecting...', 'polite');
            this.router.navigate(['/dashboard']);
        } catch (e: any) {
            this.formError.set(e.message || 'Login failed');
            this.announcer.announce(`Login failed: ${e.message}`, 'assertive');
        } finally {
            this.loading.set(false);
        }
    }
}

23. Checkpoint

🛠️ Checkpoint 12.1 — Semantic Refactor

  • เปิด component เก่า — แทน <div> ด้วย semantic tag
  • เช็คด้วย Lighthouse — score เพิ่ม?
  • Audit ด้วย axe DevTools

🛠️ Checkpoint 12.2 — Keyboard-Only Day

  • ปิดเมาส์ทั้งวัน
  • ใช้ app ของคุณด้วย keyboard เท่านั้น
  • จด pain point + แก้

🛠️ Checkpoint 12.3 — Screen Reader

  • ติดตั้ง NVDA (Win) หรือ VoiceOver (Mac)
  • เปิด app + ปิดตา
  • ใช้งาน 10 นาที — feel pain → fix

🛠️ Checkpoint 12.4 — Accessible Modal

  • ทำ modal ครบ: role="dialog", focus trap, ESC, restore focus
  • ทดสอบ keyboard + screen reader

🛠️ Checkpoint 12.5 — axe-core ใน CI

  • เพิ่ม @axe-core/playwright ใน e2e
  • Fail build ถ้ามี violation
  • Run ใน GitHub Actions

24. สรุปบท

a11y = สำคัญ — กฎหมาย + ผู้ใช้จริง + ประโยชน์ "ฟรี" (SEO/UX)
WCAG 2.2 AA = มาตรฐานหลัก (POUR — Perceivable, Operable, Understandable, Robust)
Semantic HTML ก่อน ARIA — first rule of ARIA
Heading hierarchy + label ทุก input + alt ทุก img
Keyboard navigation — Tab/Enter/ESC/Arrow ทำงาน
Focus management — skip link, focus trap (modal), restore focus
Angular CDK a11y: LiveAnnouncer, cdkTrapFocus, FocusMonitor, cdkAriaLive
Color contrast ≥ 4.5:1 + อย่าใช้สีเป็น signal เดียว
role="alert" สำหรับ error/notification ที่ปรากฏ runtime
Test: Lighthouse (auto) + axe-core (CI) + screen reader (manual)
ARIA patterns ที่ w3.org/WAI/ARIA/apg — ทำตาม


← บทที่ 11: Micro-frontends | สารบัญ | บทที่ 13: Security →