mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 03:09:50 +08:00
22 KiB
22 KiB
Accessibility Guide for LWC
Comprehensive guide to building WCAG 2.1 AA compliant Lightning Web Components.
Table of Contents
- Accessibility Standards
- Semantic HTML
- ARIA Attributes
- Keyboard Navigation
- Focus Management
- Color and Contrast
- Screen Reader Support
- Live Regions
- Form Accessibility
- Common Patterns
- Testing
- 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
<!-- BAD: Non-semantic markup -->
<div onclick={handleClick}>Click me</div>
<!-- GOOD: Semantic button -->
<button onclick={handleClick}>Click me</button>
Headings Hierarchy
<!-- 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
<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
<!-- 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
<!-- 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
<!-- 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
<!-- 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
// 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();
}
}
<!-- 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
// 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
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
/* 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
<!-- 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
<!-- 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
<!-- 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
<!-- 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
// 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);
}
}
<!-- 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
<!-- 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
<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
<!-- 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
// 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;
}
}
<!-- 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
<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
// 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
# 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
- ARIA Authoring Practices Guide
- Salesforce Lightning Design System Accessibility
- WebAIM Resources
Related Resources
- component-patterns.md - Implementation patterns
- jest-testing.md - Testing strategies
- performance-guide.md - Performance optimization