Skip to content
75 changes: 73 additions & 2 deletions .github/skills/accessibility-aria-expert/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,28 @@ Voice control users say "click Refresh" – only works if accessible name contai
<span>{isLoading ? 'Loading...' : `${count} results`}</span>
```

✅ **Fix**: Use live region
✅ **Fix**: Use the `useAnnounce` hook

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

const { announce, AnnouncerElement } = useAnnounce();

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">
Expand Down Expand Up @@ -253,6 +274,56 @@ 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:

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

### Location

```tsx
import { useAnnounce } 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>
);
```

### Options

```tsx
// For urgent announcements that interrupt (use sparingly)
const { announce, AnnouncerElement } = useAnnounce({ politeness: 'assertive' });
```

### Key Points

- **Always render `AnnouncerElement`** - it creates the ARIA live region
- **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

## Quick Checklist

- [ ] Icon-only buttons have `aria-label`
Expand All @@ -263,7 +334,7 @@ For keyboard-accessible badges with tooltips:
- [ ] 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 `role="status"` or `aria-live="polite"`
- [ ] Status updates use `useAnnounce` hook or inline `role="status"` with `aria-live="polite"`
- [ ] Focus moves to dialog/modal content when opened
- [ ] Related controls wrapped in `role="group"` with `aria-labelledby`

Expand Down
2 changes: 2 additions & 0 deletions l10n/bundle.l10n.json
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,7 @@
"No properties found in the schema at path \"{0}\"": "No properties found in the schema at path \"{0}\"",
"No public connectivity": "No public connectivity",
"No result returned from the MongoDB shell.": "No result returned from the MongoDB shell.",
"No results found": "No results found",
"No scope was provided for the role assignment.": "No scope was provided for the role assignment.",
"No session found for id {sessionId}": "No session found for id {sessionId}",
"No subscriptions found": "No subscriptions found",
Expand Down Expand Up @@ -658,6 +659,7 @@
"report an issue": "report an issue",
"Report an issue": "Report an issue",
"Resource group \"{0}\" already exists in subscription \"{1}\".": "Resource group \"{0}\" already exists in subscription \"{1}\".",
"Results found": "Results found",
"Retry": "Retry",
"Reusing active connection for \"{cluster}\".": "Reusing active connection for \"{cluster}\".",
"Revisit connection details and try again.": "Revisit connection details and try again.",
Expand Down
7 changes: 7 additions & 0 deletions src/webviews/api/webview-client/accessibility/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* 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';
111 changes: 111 additions & 0 deletions src/webviews/api/webview-client/accessibility/useAnnounce.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*---------------------------------------------------------------------------------------------
* 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 { useCallback, useRef, useState } from 'react';

/**
* Options for the useAnnounce hook
*/
export interface UseAnnounceOptions {
/**
* 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';
}

/**
* Return type for the useAnnounce hook
*/
export interface UseAnnounceReturn {
/**
* Function to trigger a screen reader announcement
* @param message - The message to announce. Pass empty string to clear.
*/
announce: (message: string) => void;

/**
* React element to render in your component tree.
* This creates the ARIA live region that screen readers listen to.
* Place this anywhere in your JSX - it's visually hidden but accessible.
*/
AnnouncerElement: React.ReactElement;
}

/**
* A React hook for making screen reader announcements using ARIA live regions.
*
* This hook provides a clean API for announcing dynamic content changes to screen readers,
* following WCAG 4.1.3 (Status Messages) guidelines. It abstracts the implementation details
* of ARIA live regions, making it easy to announce search results, loading states, errors, etc.
*
* @example
* ```tsx
* const { announce, AnnouncerElement } = useAnnounce();
*
* useEffect(() => {
* if (!isLoading && hasResults !== undefined) {
* announce(hasResults ? 'Results found' : 'No results found');
* }
* }, [isLoading, hasResults, announce]);
*
* return (
* <div>
* {AnnouncerElement}
* {// ... rest of your UI}
* </div>
* );
* ```
*
* @param options - Configuration options for the announcer
* @returns Object containing the announce function and AnnouncerElement to render
*
* @see https://www.w3.org/WAI/WCAG21/Understanding/status-messages.html
*/
export function useAnnounce(options: UseAnnounceOptions = {}): UseAnnounceReturn {
const { politeness = 'polite' } = options;

const [message, setMessage] = useState<string>('');
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const announce = useCallback((newMessage: string) => {
// Clear any pending timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}

// Clear the message first to ensure re-announcement of identical messages
setMessage('');

// Set the new message after a brief delay to ensure the live region updates
timeoutRef.current = setTimeout(() => {
setMessage(newMessage);
}, 100);
}, []);

// Styles that visually hide the element but keep it accessible to screen readers
const srOnlyStyles: React.CSSProperties = {
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
borderWidth: 0,
};

const AnnouncerElement = (
<div role="status" aria-live={politeness} aria-atomic="true" style={srOnlyStyles}>
{message}
</div>
);

return { announce, AnnouncerElement };
}
18 changes: 16 additions & 2 deletions src/webviews/documentdb/collectionView/CollectionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +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 { 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 @@ -84,6 +85,9 @@ 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 @@ -199,7 +203,12 @@ export const CollectionView = (): JSX.Element => {
pageSize: currentContext.activeQuery.pageSize,
executionIntent: currentContext.activeQuery.executionIntent ?? 'pagination',
})
.then((_response) => {
.then((response) => {
// Announce results to screen readers (skip pagination to avoid repetitive announcements)
if (currentContext.activeQuery.executionIntent !== 'pagination') {
announce(response.documentCount > 0 ? l10n.t('Results found') : l10n.t('No results found'));
}

// 2. This is the time to update the auto-completion data
// Since now we do know more about the data returned from the query
updateAutoCompletionData();
Expand Down Expand Up @@ -497,7 +506,12 @@ export const CollectionView = (): JSX.Element => {
return (
<CollectionViewContext.Provider value={[currentContext, setCurrentContext]}>
<div className="collectionView">
{currentContext.isLoading && <ProgressBar thickness="large" shape="square" className="progressBar" />}
{currentContext.isLoading && (
<ProgressBar thickness="large" shape="square" className="progressBar" aria-hidden={true} />
)}

{/* Screen reader announcements via useAnnounce hook */}
{AnnouncerElement}

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