accessibility-aria-expert
Accessibility Expert for Webviews
Verify and fix accessibility in React/Fluent UI webview components.
When to Use
- Review webview code for accessibility issues
- Fix double announcements from screen readers
- Add missing
aria-labelto icon-only buttons or form inputs - Make tooltips accessible to keyboard/screen reader users
- Announce status changes (loading, search results, errors)
- Manage focus when dialogs/modals open
- Group related controls with proper labels
Core Pattern: Tooltip Accessibility
Tooltips require aria-label + aria-hidden to avoid double announcements:
<Tooltip content="Detailed explanation">
<Badge tabIndex={0} className="focusableBadge" aria-label="Badge text. Detailed explanation">
<span aria-hidden="true">Badge text</span>
</Badge>
</Tooltip>
aria-label: Full context (visible text + tooltip)aria-hidden="true": Wraps visible text to prevent duplication- Screen reader hears: "Badge text. Detailed explanation"
Detection Rules
1. Tooltip Without aria-label Context
❌ Problem: Tooltip content inaccessible to screen readers
<Tooltip content="Save document to database">
<Button aria-label="Save">Save</Button>
</Tooltip>
✅ Fix: Include tooltip in aria-label
<Tooltip content="Save document to database" relationship="description">
<Button aria-label="Save document to database">Save</Button>
</Tooltip>
2. Missing aria-hidden (Double Announcement)
❌ Problem: Screen reader says "Collection scan Collection scan"
<Badge aria-label="Collection scan. Query is inefficient">Collection scan</Badge>
✅ Fix: Wrap visible text
<Badge aria-label="Collection scan. Query is inefficient">
<span aria-hidden="true">Collection scan</span>
</Badge>
3. Redundant aria-label (NOT Needed)
❌ Problem: aria-label identical to visible text adds no value
<Button aria-label="Save">Save</Button>
<ToolbarButton aria-label="Validate" icon={<CheckIcon />}>Validate</ToolbarButton>
✅ Fix: Remove redundant aria-label OR make it more descriptive
<Button>Save</Button>
<ToolbarButton icon={<CheckIcon />}>Validate</ToolbarButton>
Keep aria-label only when it adds information:
<ToolbarButton aria-label="Save document to database" icon={<SaveIcon />}>
Save
</ToolbarButton>
4. Icon-Only Button Missing aria-label
❌ Problem: No accessible name
<ToolbarButton icon={<DeleteRegular />} onClick={onDelete} />
✅ Fix: Add aria-label
<Tooltip content="Delete selected items" relationship="description">
<ToolbarButton aria-label="Delete selected items" icon={<DeleteRegular />} onClick={onDelete} />
</Tooltip>
5. Decorative Elements Not Hidden
❌ Problem: Progress bar announced unnecessarily
<ProgressBar thickness="large" />
✅ Fix: Hide decorative elements
<ProgressBar thickness="large" aria-hidden={true} />
6. Input Missing Accessible Name
❌ Problem: SpinButton/Input without accessible name
<SpinButton value={skipValue} onChange={onSkipChange} />
<Input placeholder="Enter query..." />
✅ Fix: Add aria-label or associate with label element
<SpinButton aria-label="Skip documents" value={skipValue} onChange={onSkipChange} />
<Label htmlFor="query-input">Query</Label>
<Input id="query-input" placeholder="Enter query..." />
7. Visible Label Not in Accessible Name
❌ Problem: aria-label doesn't contain visible text (breaks voice control)
<ToolbarButton aria-label="Reload data" icon={<RefreshIcon />}>
Refresh
</ToolbarButton>
✅ Fix: Accessible name must contain visible label exactly
<ToolbarButton aria-label="Refresh data" icon={<RefreshIcon />}>
Refresh
</ToolbarButton>
Voice control users say "click Refresh" – only works if accessible name contains "Refresh".
8. Status Changes Not Announced
❌ Problem: Screen reader doesn't announce dynamic content
<span>{isLoading ? 'Loading...' : `${count} results`}</span>
✅ Fix: Use the Announcer component
import { Announcer } from '../../api/webview-client/accessibility';
// Announces when `when` transitions from false to true
<Announcer when={isLoading} message={l10n.t('Loading...')} />
// Dynamic message based on state
<Announcer
when={!isLoading && documentCount !== undefined}
message={documentCount > 0 ? l10n.t('Results found') : l10n.t('No results found')}
/>
Use for: loading states, search results, success/error messages.
9. Dialog Opens Without Focus Move
❌ Problem: Focus stays on trigger when modal opens
{
isOpen && <Dialog>...</Dialog>;
}
✅ Fix: Move focus programmatically
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) dialogRef.current?.focus();
}, [isOpen]);
{
isOpen && (
<Dialog ref={dialogRef} tabIndex={-1} aria-modal="true">
...
</Dialog>
);
}
10. Related Controls Without Group Label
❌ Problem: Buttons share visual label but screen reader misses context
<span>How would you rate this?</span>
<Button>👍</Button>
<Button>👎</Button>
✅ Fix: Use role="group" with aria-labelledby
<div role="group" aria-labelledby="rating-label">
<span id="rating-label">How would you rate this?</span>
<Button aria-label="I like it">👍</Button>
<Button aria-label="I don't like it">👎</Button>
</div>
When to Use aria-hidden
DO use on:
- Visible text when aria-label provides complete context
- Decorative icons, spinners, progress bars
- Visual separators (`|`, `—`)
DO NOT use on:
- The only accessible content (hides it completely)
- Interactive/focusable elements
- Error messages or alerts
focusableBadge Pattern
For keyboard-accessible badges with tooltips:
- Import: `import '../components/focusableBadge/focusableBadge.scss';`
- Apply attributes:
<Badge tabIndex={0} className="focusableBadge" aria-label="Visible text. Tooltip details">
<span aria-hidden="true">Visible text</span>
</Badge>
Screen Reader Announcements
Use the Announcer component for WCAG 4.1.3 (Status Messages) compliance.
import { Announcer } from '../../api/webview-client/accessibility';
Basic Usage
// Announces "AI is analyzing..." when isLoading becomes true
<Announcer when={isLoading} message={l10n.t('AI is analyzing...')} />
// Dynamic message based on state (e.g., query results)
<Announcer
when={!isLoading && documentCount !== undefined}
message={documentCount > 0 ? l10n.t('Results found') : l10n.t('No results found')}
/>
// With assertive politeness (default is polite)
<Announcer when={hasError} message={l10n.t('Error occurred')} politeness="assertive" />
Props
when: Announces when this transitions fromfalsetotruemessage: The message to announce (usel10n.t()for localization)politeness:'assertive'(default, interrupts) or'polite'(waits for idle)
Key Points
- Placement doesn't matter - screen readers monitor all live regions regardless of DOM position; place near related UI for code readability
- Store relevant state (e.g.,
documentCount) to derive dynamic messages - Use
l10n.t()for messages - announcements must be localized - Condition resets automatically - when
whengoes back tofalse, it's ready for the next announcement - Prefer 'assertive' for user-initiated actions, 'polite' for background updates
Quick Checklist
- Icon-only buttons have
aria-label - Form inputs have associated labels or
aria-label - Tooltip content included in
aria-label - Visible text wrapped in
aria-hidden="true"when aria-label duplicates it - Redundant aria-labels removed (identical to visible text)
- Visible button labels match accessible name exactly (for voice control)
- Decorative elements have
aria-hidden={true} - Badges with tooltips use
focusableBadgeclass +tabIndex={0} - Status updates use
Announcercomponent - Focus moves to dialog/modal content when opened
- Related controls wrapped in
role="group"witharia-labelledby
References
- WCAG 2.1.1 Keyboard
- WCAG 2.4.3 Focus Order
- WCAG 2.5.3 Label in Name
- WCAG 4.1.2 Name, Role, Value
- WCAG 4.1.3 Status Messages
- See
src/webviews/components/focusableBadge/focusableBadge.mdfor the Badge pattern