mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 03:09:50 +08:00
844 lines
22 KiB
Markdown
844 lines
22 KiB
Markdown
<!-- Parent: experience-lwc-generate/SKILL.md -->
|
|
# Accessibility Guide for LWC
|
|
|
|
Comprehensive guide to building WCAG 2.1 AA compliant Lightning Web Components.
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Accessibility Standards](#accessibility-standards)
|
|
2. [Semantic HTML](#semantic-html)
|
|
3. [ARIA Attributes](#aria-attributes)
|
|
4. [Keyboard Navigation](#keyboard-navigation)
|
|
5. [Focus Management](#focus-management)
|
|
6. [Color and Contrast](#color-and-contrast)
|
|
7. [Screen Reader Support](#screen-reader-support)
|
|
8. [Live Regions](#live-regions)
|
|
9. [Form Accessibility](#form-accessibility)
|
|
10. [Common Patterns](#common-patterns)
|
|
11. [Testing](#testing)
|
|
12. [Tools and Resources](#tools-and-resources)
|
|
|
|
---
|
|
|
|
## Accessibility Standards
|
|
|
|
### WCAG 2.1 AA Compliance
|
|
|
|
All Lightning Web Components should meet WCAG 2.1 Level AA standards.
|
|
|
|
| Principle | Description |
|
|
|-----------|-------------|
|
|
| **Perceivable** | Information must be presentable to users in ways they can perceive |
|
|
| **Operable** | UI components must be operable (keyboard, mouse, voice) |
|
|
| **Understandable** | Information and UI must be understandable |
|
|
| **Robust** | Content must work with assistive technologies |
|
|
|
|
### Key Requirements
|
|
|
|
| Requirement | Standard | Implementation |
|
|
|-------------|----------|----------------|
|
|
| **Color contrast** | 4.5:1 for normal text, 3:1 for large text | Use SLDS color tokens |
|
|
| **Keyboard navigation** | All interactive elements accessible via keyboard | Tab order, Enter/Space triggers |
|
|
| **Screen reader support** | ARIA labels, roles, live regions | Proper semantic HTML + ARIA |
|
|
| **Focus indicators** | Visible focus state | Use SLDS focus utilities |
|
|
| **Alternative text** | All images have alt text | `alt` attribute on images |
|
|
|
|
---
|
|
|
|
## Semantic HTML
|
|
|
|
### Use Proper HTML Elements
|
|
|
|
```html
|
|
<!-- BAD: Non-semantic markup -->
|
|
<div onclick={handleClick}>Click me</div>
|
|
|
|
<!-- GOOD: Semantic button -->
|
|
<button onclick={handleClick}>Click me</button>
|
|
```
|
|
|
|
### Headings Hierarchy
|
|
|
|
```html
|
|
<!-- BAD: Skipping heading levels -->
|
|
<h1>Page Title</h1>
|
|
<h3>Subsection</h3> <!-- Skipped h2 -->
|
|
|
|
<!-- GOOD: Logical heading structure -->
|
|
<h1>Page Title</h1>
|
|
<h2>Main Section</h2>
|
|
<h3>Subsection</h3>
|
|
```
|
|
|
|
### Landmarks
|
|
|
|
```html
|
|
<template>
|
|
<header class="slds-page-header">
|
|
<h1>Dashboard</h1>
|
|
</header>
|
|
|
|
<nav aria-label="Primary navigation">
|
|
<ul>
|
|
<li><a href="#home">Home</a></li>
|
|
<li><a href="#accounts">Accounts</a></li>
|
|
</ul>
|
|
</nav>
|
|
|
|
<main>
|
|
<article>
|
|
<h2>Account Details</h2>
|
|
<!-- Content -->
|
|
</article>
|
|
</main>
|
|
|
|
<aside aria-label="Related information">
|
|
<!-- Sidebar content -->
|
|
</aside>
|
|
|
|
<footer>
|
|
<p>Copyright 2025</p>
|
|
</footer>
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
## ARIA Attributes
|
|
|
|
### ARIA Labels
|
|
|
|
```html
|
|
<!-- Icon button without visible text -->
|
|
<button aria-label="Delete record" onclick={handleDelete}>
|
|
<lightning-icon icon-name="utility:delete" size="small"></lightning-icon>
|
|
</button>
|
|
|
|
<!-- Form field with additional context -->
|
|
<lightning-input
|
|
label="Phone"
|
|
aria-describedby="phone-help"
|
|
value={phone}
|
|
onchange={handlePhoneChange}>
|
|
</lightning-input>
|
|
<div id="phone-help" class="slds-text-color_weak">
|
|
Enter phone number with country code
|
|
</div>
|
|
```
|
|
|
|
### ARIA Roles
|
|
|
|
```html
|
|
<!-- Custom list -->
|
|
<div role="list">
|
|
<div role="listitem">Item 1</div>
|
|
<div role="listitem">Item 2</div>
|
|
</div>
|
|
|
|
<!-- Alert message -->
|
|
<div role="alert" class="slds-notify slds-notify_alert">
|
|
<span class="slds-assistive-text">Error</span>
|
|
<p>Form validation failed</p>
|
|
</div>
|
|
|
|
<!-- Dialog/Modal -->
|
|
<div role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="modal-heading"
|
|
aria-describedby="modal-description">
|
|
<h2 id="modal-heading">Confirm Action</h2>
|
|
<p id="modal-description">Are you sure you want to delete this record?</p>
|
|
</div>
|
|
```
|
|
|
|
### ARIA States
|
|
|
|
```html
|
|
<!-- Expandable section -->
|
|
<button
|
|
aria-expanded={isExpanded}
|
|
aria-controls="details-section"
|
|
onclick={toggleExpanded}>
|
|
Show Details
|
|
</button>
|
|
<div id="details-section" class={sectionClass}>
|
|
<!-- Details content -->
|
|
</div>
|
|
|
|
<!-- Loading state -->
|
|
<div aria-busy={isLoading}>
|
|
<template lwc:if={isLoading}>
|
|
<lightning-spinner alternative-text="Loading data"></lightning-spinner>
|
|
</template>
|
|
<template lwc:else>
|
|
<!-- Content -->
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Required field -->
|
|
<lightning-input
|
|
label="Name"
|
|
required
|
|
aria-required="true"
|
|
value={name}>
|
|
</lightning-input>
|
|
```
|
|
|
|
---
|
|
|
|
## Keyboard Navigation
|
|
|
|
### Tab Order
|
|
|
|
```html
|
|
<!-- Natural tab order -->
|
|
<form>
|
|
<lightning-input label="First Name" tabindex="0"></lightning-input>
|
|
<lightning-input label="Last Name" tabindex="0"></lightning-input>
|
|
<lightning-button label="Submit" tabindex="0"></lightning-button>
|
|
</form>
|
|
|
|
<!-- Skip to main content link -->
|
|
<a href="#main-content" class="slds-assistive-text slds-assistive-text_focus">
|
|
Skip to main content
|
|
</a>
|
|
<main id="main-content">
|
|
<!-- Content -->
|
|
</main>
|
|
```
|
|
|
|
### Keyboard Event Handlers
|
|
|
|
```javascript
|
|
// accountCard.js
|
|
export default class AccountCard extends LightningElement {
|
|
handleKeyDown(event) {
|
|
// Enter or Space activates
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
this.handleSelect();
|
|
}
|
|
|
|
// Arrow navigation
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
this.focusNextItem();
|
|
} else if (event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
this.focusPreviousItem();
|
|
}
|
|
|
|
// Escape closes
|
|
if (event.key === 'Escape') {
|
|
this.handleClose();
|
|
}
|
|
}
|
|
|
|
focusNextItem() {
|
|
const items = this.template.querySelectorAll('[role="listitem"]');
|
|
const currentIndex = Array.from(items).indexOf(document.activeElement);
|
|
const nextIndex = (currentIndex + 1) % items.length;
|
|
items[nextIndex].focus();
|
|
}
|
|
|
|
focusPreviousItem() {
|
|
const items = this.template.querySelectorAll('[role="listitem"]');
|
|
const currentIndex = Array.from(items).indexOf(document.activeElement);
|
|
const prevIndex = currentIndex === 0 ? items.length - 1 : currentIndex - 1;
|
|
items[prevIndex].focus();
|
|
}
|
|
}
|
|
```
|
|
|
|
```html
|
|
<!-- accountCard.html -->
|
|
<template>
|
|
<div role="list">
|
|
<template for:each={accounts} for:item="account">
|
|
<div key={account.Id}
|
|
role="listitem"
|
|
tabindex="0"
|
|
onkeydown={handleKeyDown}
|
|
onclick={handleSelect}
|
|
data-id={account.Id}>
|
|
{account.Name}
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
## Focus Management
|
|
|
|
### Focus Trap in Modals
|
|
|
|
```javascript
|
|
// composableModal.js
|
|
export default class ComposableModal extends LightningElement {
|
|
_focusableElements = [];
|
|
_isOpen = false;
|
|
|
|
@api
|
|
toggleModal() {
|
|
this._isOpen = !this._isOpen;
|
|
|
|
if (this._isOpen) {
|
|
// Capture focusable elements
|
|
this._focusableElements = this.getFocusableElements();
|
|
|
|
// Focus first element
|
|
requestAnimationFrame(() => {
|
|
this._focusableElements[0]?.focus();
|
|
});
|
|
|
|
// Add keyboard listener
|
|
window.addEventListener('keydown', this._handleKeyDown);
|
|
|
|
// Store previous focus
|
|
this._previousFocus = document.activeElement;
|
|
} else {
|
|
// Remove keyboard listener
|
|
window.removeEventListener('keydown', this._handleKeyDown);
|
|
|
|
// Restore focus
|
|
this._previousFocus?.focus();
|
|
}
|
|
}
|
|
|
|
getFocusableElements() {
|
|
const selector = [
|
|
'a[href]',
|
|
'button:not([disabled])',
|
|
'input:not([disabled])',
|
|
'select:not([disabled])',
|
|
'textarea:not([disabled])',
|
|
'[tabindex]:not([tabindex="-1"])'
|
|
].join(',');
|
|
|
|
return Array.from(this.template.querySelectorAll(selector));
|
|
}
|
|
|
|
_handleKeyDown = (event) => {
|
|
if (event.key === 'Tab') {
|
|
this.trapFocus(event);
|
|
} else if (event.key === 'Escape') {
|
|
this.toggleModal();
|
|
}
|
|
}
|
|
|
|
trapFocus(event) {
|
|
const firstElement = this._focusableElements[0];
|
|
const lastElement = this._focusableElements[this._focusableElements.length - 1];
|
|
const activeElement = this.template.activeElement;
|
|
|
|
if (event.shiftKey) {
|
|
// Shift+Tab: Moving backward
|
|
if (activeElement === firstElement) {
|
|
event.preventDefault();
|
|
lastElement.focus();
|
|
}
|
|
} else {
|
|
// Tab: Moving forward
|
|
if (activeElement === lastElement) {
|
|
event.preventDefault();
|
|
firstElement.focus();
|
|
}
|
|
}
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
window.removeEventListener('keydown', this._handleKeyDown);
|
|
}
|
|
}
|
|
```
|
|
|
|
### Managing Focus After Actions
|
|
|
|
```javascript
|
|
handleDelete(event) {
|
|
const itemId = event.target.dataset.id;
|
|
const itemIndex = this.items.findIndex(item => item.Id === itemId);
|
|
|
|
// Delete item
|
|
this.items = this.items.filter(item => item.Id !== itemId);
|
|
|
|
// Focus next item or previous if last item deleted
|
|
requestAnimationFrame(() => {
|
|
const focusIndex = itemIndex < this.items.length ? itemIndex : itemIndex - 1;
|
|
if (focusIndex >= 0) {
|
|
const nextItem = this.template.querySelector(`[data-id="${this.items[focusIndex].Id}"]`);
|
|
nextItem?.focus();
|
|
}
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Color and Contrast
|
|
|
|
### Use SLDS Color Tokens
|
|
|
|
```css
|
|
/* BAD: Hardcoded colors */
|
|
.my-component {
|
|
color: #333333;
|
|
background-color: #ffffff;
|
|
border-color: #dddddd;
|
|
}
|
|
|
|
/* GOOD: SLDS tokens with proper contrast */
|
|
.my-component {
|
|
color: var(--slds-g-color-on-surface, #181818);
|
|
background-color: var(--slds-g-color-surface-container-1, #ffffff);
|
|
border-color: var(--slds-g-color-border-1, #c9c9c9);
|
|
}
|
|
```
|
|
|
|
### Testing Contrast
|
|
|
|
```html
|
|
<!-- Text: 4.5:1 minimum contrast ratio -->
|
|
<p class="slds-text-body_regular">Regular body text</p>
|
|
|
|
<!-- Large text (18pt+): 3:1 minimum -->
|
|
<h1 class="slds-text-heading_large">Large heading</h1>
|
|
|
|
<!-- Links: Must be distinguishable from surrounding text -->
|
|
<p>
|
|
Visit our <a href="/help" class="slds-text-link">help center</a> for support.
|
|
</p>
|
|
```
|
|
|
|
### Color Independence
|
|
|
|
```html
|
|
<!-- BAD: Relies only on color to convey status -->
|
|
<span class="text-red">Error</span>
|
|
|
|
<!-- GOOD: Uses icon + text + color -->
|
|
<span class="slds-text-color_error">
|
|
<lightning-icon icon-name="utility:error" size="x-small"></lightning-icon>
|
|
Error
|
|
</span>
|
|
|
|
<!-- GOOD: Status indicators with patterns -->
|
|
<div class="slds-badge slds-theme_error">
|
|
<lightning-icon icon-name="utility:close" size="xx-small"></lightning-icon>
|
|
Failed
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
## Screen Reader Support
|
|
|
|
### Assistive Text
|
|
|
|
```html
|
|
<!-- Hidden text for screen readers -->
|
|
<span class="slds-assistive-text">Required field</span>
|
|
<lightning-input label="Email" required value={email}></lightning-input>
|
|
|
|
<!-- Button with icon only -->
|
|
<button aria-label="Edit record">
|
|
<lightning-icon icon-name="utility:edit" size="small"></lightning-icon>
|
|
<span class="slds-assistive-text">Edit</span>
|
|
</button>
|
|
|
|
<!-- Loading state announcement -->
|
|
<template lwc:if={isLoading}>
|
|
<span class="slds-assistive-text">Loading data, please wait</span>
|
|
<lightning-spinner size="small"></lightning-spinner>
|
|
</template>
|
|
```
|
|
|
|
### Image Alternative Text
|
|
|
|
```html
|
|
<!-- Decorative images (no alt needed, hide from screen readers) -->
|
|
<img src="decorative-icon.png" alt="" role="presentation">
|
|
|
|
<!-- Informative images (descriptive alt text) -->
|
|
<img src="chart.png" alt="Sales trend showing 15% increase over last quarter">
|
|
|
|
<!-- Functional images (describe action) -->
|
|
<a href="/profile">
|
|
<img src="user-avatar.png" alt="View your profile">
|
|
</a>
|
|
```
|
|
|
|
---
|
|
|
|
## Live Regions
|
|
|
|
### ARIA Live Regions
|
|
|
|
```javascript
|
|
// notificationComponent.js
|
|
export default class NotificationComponent extends LightningElement {
|
|
@track messages = [];
|
|
|
|
addMessage(message, type = 'info') {
|
|
const id = Date.now();
|
|
this.messages = [...this.messages, { id, message, type }];
|
|
|
|
// Auto-remove after 5 seconds
|
|
setTimeout(() => {
|
|
this.messages = this.messages.filter(m => m.id !== id);
|
|
}, 5000);
|
|
}
|
|
}
|
|
```
|
|
|
|
```html
|
|
<!-- notificationComponent.html -->
|
|
<template>
|
|
<!-- Polite: Announced after current speech -->
|
|
<div aria-live="polite" aria-atomic="true" class="slds-assistive-text">
|
|
<template for:each={messages} for:item="msg">
|
|
<p key={msg.id}>{msg.message}</p>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Visual notifications -->
|
|
<div class="slds-notify-container">
|
|
<template for:each={messages} for:item="msg">
|
|
<div key={msg.id} class={msg.type} role="status">
|
|
<p>{msg.message}</p>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
### Assertive vs Polite
|
|
|
|
```html
|
|
<!-- Polite: Non-urgent updates (search results, status changes) -->
|
|
<div aria-live="polite" class="slds-assistive-text">
|
|
{searchResultsCount} results found
|
|
</div>
|
|
|
|
<!-- Assertive: Urgent messages (errors, warnings) -->
|
|
<div aria-live="assertive" role="alert" class="slds-notify slds-notify_alert">
|
|
<span class="slds-assistive-text">Error</span>
|
|
Form submission failed. Please correct the errors and try again.
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
## Form Accessibility
|
|
|
|
### Accessible Form Fields
|
|
|
|
```html
|
|
<template>
|
|
<form onsubmit={handleSubmit}>
|
|
<!-- Required field with validation -->
|
|
<lightning-input
|
|
label="Email"
|
|
type="email"
|
|
name="email"
|
|
required
|
|
value={email}
|
|
onchange={handleEmailChange}
|
|
message-when-value-missing="Email is required"
|
|
message-when-bad-input="Please enter a valid email">
|
|
</lightning-input>
|
|
|
|
<!-- Field with help text -->
|
|
<lightning-input
|
|
label="Phone"
|
|
type="tel"
|
|
value={phone}
|
|
field-level-help="Enter phone number with country code"
|
|
aria-describedby="phone-help"
|
|
onchange={handlePhoneChange}>
|
|
</lightning-input>
|
|
<div id="phone-help" class="slds-text-color_weak slds-m-top_xx-small">
|
|
Format: +1 (555) 555-5555
|
|
</div>
|
|
|
|
<!-- Error state -->
|
|
<template lwc:if={errors.industry}>
|
|
<lightning-input
|
|
label="Industry"
|
|
value={industry}
|
|
variant="label-hidden"
|
|
aria-invalid="true"
|
|
aria-describedby="industry-error"
|
|
class="slds-has-error">
|
|
</lightning-input>
|
|
<div id="industry-error" class="slds-form-element__help" role="alert">
|
|
{errors.industry}
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Submit button -->
|
|
<lightning-button
|
|
type="submit"
|
|
label="Save"
|
|
variant="brand"
|
|
disabled={isSubmitting}>
|
|
</lightning-button>
|
|
</form>
|
|
</template>
|
|
```
|
|
|
|
### Fieldset and Legend
|
|
|
|
```html
|
|
<!-- Radio button group -->
|
|
<fieldset class="slds-form-element">
|
|
<legend class="slds-form-element__legend slds-form-element__label">
|
|
Contact Method <abbr class="slds-required" title="required">*</abbr>
|
|
</legend>
|
|
<div class="slds-form-element__control">
|
|
<lightning-radio-group
|
|
name="contactMethod"
|
|
label="Contact Method"
|
|
options={contactOptions}
|
|
value={selectedMethod}
|
|
onchange={handleMethodChange}
|
|
variant="label-hidden"
|
|
required>
|
|
</lightning-radio-group>
|
|
</div>
|
|
</fieldset>
|
|
```
|
|
|
|
---
|
|
|
|
## Common Patterns
|
|
|
|
### Accessible Tabs
|
|
|
|
```javascript
|
|
// tabsComponent.js
|
|
export default class TabsComponent extends LightningElement {
|
|
@track activeTab = 'tab1';
|
|
|
|
handleTabKeyDown(event) {
|
|
const tabs = Array.from(this.template.querySelectorAll('[role="tab"]'));
|
|
const currentIndex = tabs.indexOf(event.target);
|
|
|
|
let nextIndex;
|
|
if (event.key === 'ArrowRight') {
|
|
nextIndex = (currentIndex + 1) % tabs.length;
|
|
} else if (event.key === 'ArrowLeft') {
|
|
nextIndex = currentIndex === 0 ? tabs.length - 1 : currentIndex - 1;
|
|
} else if (event.key === 'Home') {
|
|
nextIndex = 0;
|
|
} else if (event.key === 'End') {
|
|
nextIndex = tabs.length - 1;
|
|
}
|
|
|
|
if (nextIndex !== undefined) {
|
|
event.preventDefault();
|
|
tabs[nextIndex].focus();
|
|
this.activeTab = tabs[nextIndex].dataset.tab;
|
|
}
|
|
}
|
|
|
|
handleTabClick(event) {
|
|
this.activeTab = event.currentTarget.dataset.tab;
|
|
}
|
|
}
|
|
```
|
|
|
|
```html
|
|
<!-- tabsComponent.html -->
|
|
<template>
|
|
<div class="slds-tabs_default">
|
|
<ul role="tablist" class="slds-tabs_default__nav">
|
|
<li class="slds-tabs_default__item" role="presentation">
|
|
<a role="tab"
|
|
tabindex={tab1Tabindex}
|
|
aria-selected={isTab1Active}
|
|
aria-controls="tab1-panel"
|
|
data-tab="tab1"
|
|
onclick={handleTabClick}
|
|
onkeydown={handleTabKeyDown}>
|
|
Tab 1
|
|
</a>
|
|
</li>
|
|
<li class="slds-tabs_default__item" role="presentation">
|
|
<a role="tab"
|
|
tabindex={tab2Tabindex}
|
|
aria-selected={isTab2Active}
|
|
aria-controls="tab2-panel"
|
|
data-tab="tab2"
|
|
onclick={handleTabClick}
|
|
onkeydown={handleTabKeyDown}>
|
|
Tab 2
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
|
|
<div id="tab1-panel"
|
|
role="tabpanel"
|
|
aria-labelledby="tab1"
|
|
class={tab1PanelClass}>
|
|
<!-- Tab 1 content -->
|
|
</div>
|
|
|
|
<div id="tab2-panel"
|
|
role="tabpanel"
|
|
aria-labelledby="tab2"
|
|
class={tab2PanelClass}>
|
|
<!-- Tab 2 content -->
|
|
</div>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
### Accessible Data Table
|
|
|
|
```html
|
|
<template>
|
|
<table class="slds-table slds-table_bordered" role="grid">
|
|
<thead>
|
|
<tr>
|
|
<th scope="col" role="columnheader">
|
|
<span class="slds-truncate">Account Name</span>
|
|
</th>
|
|
<th scope="col" role="columnheader">
|
|
<span class="slds-truncate">Industry</span>
|
|
</th>
|
|
<th scope="col" role="columnheader">
|
|
<span class="slds-truncate">Actions</span>
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<template for:each={accounts} for:item="account">
|
|
<tr key={account.Id} role="row">
|
|
<th scope="row" role="gridcell">
|
|
<a href={account.link}>{account.Name}</a>
|
|
</th>
|
|
<td role="gridcell">
|
|
{account.Industry}
|
|
</td>
|
|
<td role="gridcell">
|
|
<button aria-label={account.editLabel}
|
|
data-id={account.Id}
|
|
onclick={handleEdit}>
|
|
<lightning-icon icon-name="utility:edit" size="x-small"></lightning-icon>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
### Automated Testing
|
|
|
|
```javascript
|
|
// Jest accessibility tests
|
|
it('has proper ARIA labels', () => {
|
|
const element = createElement('c-my-component', {
|
|
is: MyComponent
|
|
});
|
|
document.body.appendChild(element);
|
|
|
|
const button = element.shadowRoot.querySelector('button');
|
|
expect(button.getAttribute('aria-label')).toBeTruthy();
|
|
});
|
|
|
|
it('manages focus when modal opens', async () => {
|
|
const element = createElement('c-modal', { is: Modal });
|
|
document.body.appendChild(element);
|
|
|
|
element.openModal();
|
|
await flushPromises();
|
|
|
|
const firstFocusable = element.shadowRoot.querySelector('.focusable');
|
|
expect(document.activeElement).toBe(firstFocusable);
|
|
});
|
|
|
|
it('announces status changes to screen readers', async () => {
|
|
const element = createElement('c-notification', {
|
|
is: Notification
|
|
});
|
|
document.body.appendChild(element);
|
|
|
|
element.showMessage('Success');
|
|
await flushPromises();
|
|
|
|
const liveRegion = element.shadowRoot.querySelector('[aria-live]');
|
|
expect(liveRegion.textContent).toContain('Success');
|
|
});
|
|
```
|
|
|
|
### Manual Testing Checklist
|
|
|
|
- [ ] Navigate entire component using only keyboard (Tab, Shift+Tab, Enter, Space, Arrows)
|
|
- [ ] Test with screen reader (NVDA, JAWS, VoiceOver)
|
|
- [ ] Verify color contrast ratios (4.5:1 minimum for text)
|
|
- [ ] Test at 200% zoom
|
|
- [ ] Verify focus indicators are visible
|
|
- [ ] Test with high contrast mode
|
|
- [ ] Verify all interactive elements have accessible names
|
|
- [ ] Test form validation announcements
|
|
|
|
---
|
|
|
|
## Tools and Resources
|
|
|
|
### Browser Extensions
|
|
|
|
| Tool | Purpose |
|
|
|------|---------|
|
|
| **axe DevTools** | Automated accessibility testing |
|
|
| **Lighthouse** | Built into Chrome DevTools, accessibility audit |
|
|
| **WAVE** | Visual accessibility evaluation |
|
|
| **Color Contrast Analyzer** | Check WCAG contrast compliance |
|
|
|
|
### Screen Readers
|
|
|
|
| Platform | Screen Reader |
|
|
|----------|---------------|
|
|
| Windows | NVDA (free), JAWS |
|
|
| macOS | VoiceOver (built-in) |
|
|
| iOS | VoiceOver (built-in) |
|
|
| Android | TalkBack (built-in) |
|
|
|
|
### Testing Commands
|
|
|
|
```bash
|
|
# Run axe accessibility tests
|
|
npm install --save-dev @axe-core/cli
|
|
axe https://your-app.lightning.force.com
|
|
|
|
# Lighthouse CLI
|
|
npm install -g lighthouse
|
|
lighthouse https://your-app.lightning.force.com --only-categories=accessibility
|
|
```
|
|
|
|
### Resources
|
|
|
|
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
|
|
- [ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/)
|
|
- [Salesforce Lightning Design System Accessibility](https://www.lightningdesignsystem.com/accessibility/overview/)
|
|
- [WebAIM Resources](https://webaim.org/resources/)
|
|
|
|
---
|
|
|
|
## Related Resources
|
|
|
|
- [component-patterns.md](component-patterns.md) - Implementation patterns
|
|
- [jest-testing.md](jest-testing.md) - Testing strategies
|
|
- [performance-guide.md](performance-guide.md) - Performance optimization
|