Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 33 additions & 59 deletions .github/skills/accessibility-aria-expert/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,36 +168,22 @@ Voice control users say "click Refresh" – only works if accessible name contai
<span>{isLoading ? 'Loading...' : `${count} results`}</span>
```

✅ **Fix**: Use the `useAnnounce` hook
✅ **Fix**: Use the `Announcer` component

```tsx
import { useAnnounce } from '../../api/webview-client/accessibility';
import { Announcer } from '../../api/webview-client/accessibility';

const { announce, AnnouncerElement } = useAnnounce();
// Announces when `when` transitions from false to true
<Announcer when={isLoading} message={l10n.t('Loading...')} />

useEffect(() => {
if (!isLoading && hasResults !== undefined) {
announce(hasResults ? l10n.t('Results found') : l10n.t('No results found'));
}
}, [isLoading, hasResults, announce]);

return (
<div>
{AnnouncerElement}
{/* ... rest of your UI */}
</div>
);
```

**Alternative (inline live region)**: For simple cases without the hook:

```tsx
<span role="status" aria-live="polite">
{isLoading ? 'Loading...' : `${count} results`}
</span>
// Dynamic message based on state
<Announcer
when={!isLoading && documentCount !== undefined}
message={documentCount > 0 ? l10n.t('Results found') : l10n.t('No results found')}
/>
```

Use for: search results, loading states, success/error messages.
Use for: loading states, search results, success/error messages.

### 9. Dialog Opens Without Focus Move

Expand Down Expand Up @@ -274,55 +260,43 @@ For keyboard-accessible badges with tooltips:
</Badge>
```

## useAnnounce Hook

The `useAnnounce` hook provides a clean API for screen reader announcements following WCAG 4.1.3 (Status Messages). Use it for:
## Screen Reader Announcements

- Search results ("Results found" / "No results found")
- Loading completion
- Success/error messages
- Any dynamic status changes

### Location
Use the `Announcer` component for WCAG 4.1.3 (Status Messages) compliance.

```tsx
import { useAnnounce } from '../../api/webview-client/accessibility';
import { Announcer } from '../../api/webview-client/accessibility';
```

### Basic Usage

```tsx
const { announce, AnnouncerElement } = useAnnounce();

// Call announce directly in async completion handlers
// This ensures announcements work even when the result is the same as before
trpcClient.someQuery.query(params).then((response) => {
announce(response.count > 0 ? l10n.t('Results found') : l10n.t('No results found'));
});

return (
<div>
{AnnouncerElement} {/* Place anywhere in JSX - visually hidden */}
{/* ... rest of UI */}
</div>
);
```
// Announces "AI is analyzing..." when isLoading becomes true
<Announcer when={isLoading} message={l10n.t('AI is analyzing...')} />

### Options
// 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')}
/>

```tsx
// For urgent announcements that interrupt (use sparingly)
const { announce, AnnouncerElement } = useAnnounce({ politeness: 'assertive' });
// With assertive politeness (default is polite)
<Announcer when={hasError} message={l10n.t('Error occurred')} politeness="assertive" />
```

### Props

- `when`: Announces when this transitions from `false` to `true`
- `message`: The message to announce (use `l10n.t()` for localization)
- `politeness`: `'assertive'` (default, interrupts) or `'polite'` (waits for idle)

### Key Points

- **Always render `AnnouncerElement`** - it creates the ARIA live region
- **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
- **Call `announce` directly in callbacks** - don't rely on state changes (useEffect won't trigger if state value stays the same)
- **Identical messages re-announce** - the hook handles this automatically via internal timeout
- **Prefer 'polite' (default)** - only use 'assertive' for critical errors
- **Skip repetitive operations** - e.g., suppress during pagination to avoid noise
- **Condition resets automatically** - when `when` goes back to `false`, it's ready for the next announcement
- **Prefer 'assertive'** for user-initiated actions, 'polite' for background updates

## Quick Checklist

Expand All @@ -334,7 +308,7 @@ const { announce, AnnouncerElement } = useAnnounce({ politeness: 'assertive' });
- [ ] Visible button labels match accessible name exactly (for voice control)
- [ ] Decorative elements have `aria-hidden={true}`
- [ ] Badges with tooltips use `focusableBadge` class + `tabIndex={0}`
- [ ] Status updates use `useAnnounce` hook or inline `role="status"` with `aria-live="polite"`
- [ ] Status updates use `Announcer` component
- [ ] Focus moves to dialog/modal content when opened
- [ ] Related controls wrapped in `role="group"` with `aria-labelledby`

Expand Down
81 changes: 81 additions & 0 deletions src/webviews/api/webview-client/accessibility/Announcer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as React from 'react';
import { useEffect, useRef, useState } from 'react';

export interface AnnouncerProps {
/**
* When true, the message will be announced to screen readers.
* Announcement triggers on transition from false to true.
*/
when: boolean;

/**
* The message to announce to screen readers.
*/
message: string;

/**
* The politeness level of the announcement.
* - 'polite': Waits for the user to finish their current activity before announcing (default)
* - 'assertive': Interrupts the user immediately (use sparingly)
* @default 'polite'
*/
politeness?: 'polite' | 'assertive';
}

/**
* A declarative component for screen reader announcements.
*
* Announces the message when `when` transitions from false to true.
* Uses ARIA live regions following WCAG 4.1.3 (Status Messages).
*
* @example
* ```tsx
* <Announcer when={isLoading} message={l10n.t('AI is analyzing...')} />
* ```
*/
export function Announcer({ when, message, politeness = 'polite' }: AnnouncerProps): React.ReactElement {
const [announcement, setAnnouncement] = useState('');
const wasActiveRef = useRef(false);

useEffect(() => {
if (when && !wasActiveRef.current) {
// Transition to active - announce with delay for NVDA compatibility
setAnnouncement('');
const timer = setTimeout(() => setAnnouncement(message), 100);
wasActiveRef.current = true;
return () => clearTimeout(timer);
} else if (!when) {
// Reset for next activation
wasActiveRef.current = false;
setAnnouncement('');
}
return undefined;
}, [when, message]);

// Visually hidden but accessible to screen readers
return (
<div
role="status"
aria-live={politeness}
aria-atomic="true"
style={{
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
borderWidth: 0,
}}
>
{announcement}
</div>
);
}
4 changes: 2 additions & 2 deletions src/webviews/api/webview-client/accessibility/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

export { useAnnounce } from './useAnnounce';
export type { UseAnnounceOptions, UseAnnounceReturn } from './useAnnounce';
export { Announcer } from './Announcer';
export type { AnnouncerProps } from './Announcer';
111 changes: 0 additions & 111 deletions src/webviews/api/webview-client/accessibility/useAnnounce.tsx

This file was deleted.

24 changes: 16 additions & 8 deletions src/webviews/documentdb/collectionView/CollectionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import * as l10n from '@vscode/l10n';
import { type JSX, useEffect, useRef, useState } from 'react';
import { type TableDataEntry } from '../../../documentdb/ClusterSession';
import { UsageImpact } from '../../../utils/surveyTypes';
import { useAnnounce } from '../../api/webview-client/accessibility';
import { Announcer } from '../../api/webview-client/accessibility';
import { useConfiguration } from '../../api/webview-client/useConfiguration';
import { useTrpcClient } from '../../api/webview-client/useTrpcClient';
import { useSelectiveContextMenuPrevention } from '../../api/webview-client/utils/useSelectiveContextMenuPrevention';
Expand Down Expand Up @@ -40,6 +40,9 @@ interface QueryResults {
treeData?: { [key: string]: unknown }[];

jsonDocuments?: string[];

/** Number of documents returned by the query (for screen reader announcements) */
documentCount?: number;
}

export const CollectionView = (): JSX.Element => {
Expand Down Expand Up @@ -85,9 +88,6 @@ export const CollectionView = (): JSX.Element => {
// TODO: it's a potential data duplication in the end, consider moving it into the global context of the view
const [currentQueryResults, setCurrentQueryResults] = useState<QueryResults>();

// Screen reader announcements
const { announce, AnnouncerElement } = useAnnounce();

// Track which tab is currently active
const [selectedTab, setSelectedTab] = useState<'tab_result' | 'tab_queryInsights'>('tab_result');

Expand Down Expand Up @@ -204,9 +204,9 @@ export const CollectionView = (): JSX.Element => {
executionIntent: currentContext.activeQuery.executionIntent ?? 'pagination',
})
.then((response) => {
// Announce results to screen readers (skip pagination to avoid repetitive announcements)
// Store document count for screen reader announcements (skip pagination)
if (currentContext.activeQuery.executionIntent !== 'pagination') {
announce(response.documentCount > 0 ? l10n.t('Results found') : l10n.t('No results found'));
setCurrentQueryResults((prev) => ({ ...prev, documentCount: response.documentCount }));
}

// 2. This is the time to update the auto-completion data
Expand Down Expand Up @@ -510,8 +510,16 @@ export const CollectionView = (): JSX.Element => {
<ProgressBar thickness="large" shape="square" className="progressBar" aria-hidden={true} />
)}

{/* Screen reader announcements via useAnnounce hook */}
{AnnouncerElement}
{/* Screen reader announcement when query completes */}
<Announcer
when={!currentContext.isLoading && currentQueryResults?.documentCount !== undefined}
politeness="assertive"
message={
(currentQueryResults?.documentCount ?? 0) > 0
? l10n.t('Results found')
: l10n.t('No results found')
}
/>

<div className="toolbarMainView">
<ToolbarMainView />
Expand Down
Loading