โหมดมืด
บทที่ 12 — Accessibility (a11y) ใน Angular
📌 คำว่า 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> ทุกตัวมี alt | screen reader อ่านชื่อไฟล์ img3847.jpg แทน — ผู้ใช้งงว่ารูปคืออะไร |
| 1.3.1 Info + relationships | Semantic 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 order | Tab 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, value | ARIA 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>© 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 |
Button vs Link — สำคัญมาก
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 auto5. 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-label | label สำหรับ AT (ทดแทน visible text) | ปุ่ม icon-only เช่นปุ่ม ✕ ปิด modal |
aria-labelledby | id ของ element ที่เป็น label | dialog ที่มี <h2> เป็นชื่อ |
aria-describedby | id ของ description | input ที่มี hint/error อยู่คนละ element |
aria-hidden="true" | ซ่อนจาก AT | icon ที่อยู่ข้างข้อความซ้ำ (เช่น ✓ Success) |
aria-expanded | true/false (collapsible) | ปุ่มที่เปิด/ปิด panel เช่น dropdown menu, accordion |
aria-controls | id ของ element ที่ควบคุม | tab → tabpanel |
aria-current | "page" / "step" / "true" | nav link ของหน้าปัจจุบัน, step ปัจจุบันใน wizard |
aria-live | polite / assertive (announce changes) | live region ที่อัปเดต เช่น cart count, toast |
aria-busy | true (loading state) | ปุ่ม Save ขณะ loading |
aria-disabled | true/false | ปุ่มที่ disable แต่ยังโฟกัสได้ (ต่างจาก disabled ที่ skip Tab) |
aria-invalid | true/false (form error) | input ที่ validation ไม่ผ่าน |
aria-required | true | input บังคับกรอก |
aria-selected | true/false (tab, option) | tab ที่ active, item ใน listbox |
aria-pressed | toggle 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, listitem8. Keyboard Navigation
📌 Focus คือตำแหน่งที่ keyboard กำลังโฟกัสอยู่บนหน้า — กดปุ่ม Tab เพื่อย้าย focus ไปยัง element ถัดไป, Shift+Tab ย้อนกลับ
ทุก interactive element ต้อง:
- Focusable ด้วย Tab
- ทำงานด้วย Enter/Space
- ESC ปิด modal/menu
- 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();
});
}
}⚠️
afterNextRenderonly 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 เท่านั้น
💡
cdkTrapFocusvscdkTrapFocusAutoCapture:
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 destroystopMonitoring= 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 นี้คุม columnscope="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/materialtypescript
import { MatTableModule } from '@angular/material/table';
// MatTable มี a11y built-in14. 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/playwrighttypescript
// 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-coretypescript
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: TalkBackNVDA 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> ไม่มี alt | alt="..." หรือ alt="" | screen reader อ่าน filename |
| Modal ไม่มี focus trap | ใช้ cdkTrapFocus | Tab หลุดออก modal |
| ESC ไม่ปิด modal | (keydown.escape) | UX มาตรฐาน |
| Form error ไม่มี role="alert" | role="alert" | AT ไม่ announce |
| Color-only signal | color + icon + text | colorblind ไม่เห็น |
| Auto-play media | ให้ผู้ใช้เป็นคนกดเล่นเอง (user-initiated) | ผู้ที่ใช้ screen reader จะตกใจและรำคาญเมื่อมีเสียงเล่นทันทีที่เปิดหน้า |
| Carousel auto-advance | pause control | ผู้ใช้อ่านช้าไม่ทัน |
| Skip to main ไม่มี | เพิ่ม skip link | keyboard 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 sound20. 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
- Angular CDK a11y
- WCAG 2.2
- WAI-ARIA Practices
- Inclusive Components (Heydon Pickering)
Tools
- axe DevTools — Chrome / Firefox extension
- WAVE — visual a11y indicator
- Lighthouse — built-in Chrome
- Pa11y — CLI 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 — ทำตาม