Skip to content

Commit 1c7ae2d

Browse files
docs-botCopilotstevesCopilot
authored
fix: debounce ARIA live region for no-results announcement (a11y #16463) (#61596)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Steve S <steves@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: steves <steves@github.com>
1 parent 3e6460b commit 1c7ae2d

2 files changed

Lines changed: 27 additions & 8 deletions

File tree

src/fixtures/tests/playwright-rendering.spec.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1399,9 +1399,7 @@ test.describe('LandingArticleGridWithFilter component', () => {
13991399
// Should show "no articles found" message as well
14001400
const noResultsMessage = page.getByTestId('no-articles-message')
14011401
await expect(noResultsMessage).toBeVisible()
1402-
await expect(page.locator('[aria-live="polite"][aria-atomic="true"]')).toHaveText(
1403-
'No articles found matching your criteria.',
1404-
)
1402+
await expect(noResultsMessage).toHaveText('No articles found matching your criteria.')
14051403
})
14061404

14071405
test('responsive behavior on different screen sizes', async ({ page }) => {

src/landings/components/shared/LandingArticleGridWithFilter.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useState, useRef, useEffect, useMemo } from 'react'
22
import { TextInput, ActionMenu, ActionList, Token, Pagination } from '@primer/react'
33
import { SearchIcon } from '@primer/octicons-react'
4+
import { announce } from '@primer/live-region-element'
45
import cx from 'classnames'
56

67
import { Link } from '@/frame/components/Link'
@@ -61,6 +62,7 @@ export const ArticleGrid = ({
6162

6263
const inputRef = useRef<HTMLInputElement>(null)
6364
const headingRef = useRef<HTMLHeadingElement>(null)
65+
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
6466

6567
// Read filter state directly from query params
6668
const searchQuery = params['articles-filter'] || ''
@@ -230,6 +232,25 @@ export const ArticleGrid = ({
230232
prevPageRef.current = currentPage
231233
}, [currentPage])
232234

235+
// Announce search/filter no-results to assistive technologies.
236+
// Uses @primer/live-region-element which renders a <live-region> web component
237+
// with a shadow DOM on document.body — completely isolated from React's component
238+
// tree. This avoids VoiceOver re-announcing the focused input when React re-renders
239+
// cause DOM mutations near the TextInput.
240+
const noArticlesFoundMessage = t('article_grid.no_articles_found')
241+
useEffect(() => {
242+
if (statusTimerRef.current) clearTimeout(statusTimerRef.current)
243+
244+
if (filteredResults.length === 0) {
245+
statusTimerRef.current = setTimeout(() => {
246+
announce(noArticlesFoundMessage, { politeness: 'assertive' })
247+
}, 750)
248+
}
249+
250+
return () => {
251+
if (statusTimerRef.current) clearTimeout(statusTimerRef.current)
252+
}
253+
}, [filteredResults.length, searchQuery, selectedCategory, noArticlesFoundMessage])
233254
return (
234255
<div data-testid="article-grid-container">
235256
{/* Filter and Search Controls */}
@@ -294,14 +315,14 @@ export const ArticleGrid = ({
294315
/>
295316
))}
296317
{filteredResults.length === 0 && (
297-
<div className={styles.noArticlesContainer} data-testid="no-articles-message">
318+
<div
319+
className={styles.noArticlesContainer}
320+
data-testid="no-articles-message"
321+
aria-hidden="true"
322+
>
298323
<p className={styles.noArticlesText}>{t('article_grid.no_articles_found')}</p>
299324
</div>
300325
)}
301-
302-
<div aria-live="polite" aria-atomic="true" className="visually-hidden">
303-
{filteredResults.length === 0 ? t('article_grid.no_articles_found') : ''}
304-
</div>
305326
</div>
306327

307328
{/* Pagination */}

0 commit comments

Comments
 (0)