|
1 | 1 | import React, { useState, useRef, useEffect, useMemo } from 'react' |
2 | 2 | import { TextInput, ActionMenu, ActionList, Token, Pagination } from '@primer/react' |
3 | 3 | import { SearchIcon } from '@primer/octicons-react' |
| 4 | +import { announce } from '@primer/live-region-element' |
4 | 5 | import cx from 'classnames' |
5 | 6 |
|
6 | 7 | import { Link } from '@/frame/components/Link' |
@@ -61,6 +62,7 @@ export const ArticleGrid = ({ |
61 | 62 |
|
62 | 63 | const inputRef = useRef<HTMLInputElement>(null) |
63 | 64 | const headingRef = useRef<HTMLHeadingElement>(null) |
| 65 | + const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) |
64 | 66 |
|
65 | 67 | // Read filter state directly from query params |
66 | 68 | const searchQuery = params['articles-filter'] || '' |
@@ -230,6 +232,25 @@ export const ArticleGrid = ({ |
230 | 232 | prevPageRef.current = currentPage |
231 | 233 | }, [currentPage]) |
232 | 234 |
|
| 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]) |
233 | 254 | return ( |
234 | 255 | <div data-testid="article-grid-container"> |
235 | 256 | {/* Filter and Search Controls */} |
@@ -294,14 +315,14 @@ export const ArticleGrid = ({ |
294 | 315 | /> |
295 | 316 | ))} |
296 | 317 | {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 | + > |
298 | 323 | <p className={styles.noArticlesText}>{t('article_grid.no_articles_found')}</p> |
299 | 324 | </div> |
300 | 325 | )} |
301 | | - |
302 | | - <div aria-live="polite" aria-atomic="true" className="visually-hidden"> |
303 | | - {filteredResults.length === 0 ? t('article_grid.no_articles_found') : ''} |
304 | | - </div> |
305 | 326 | </div> |
306 | 327 |
|
307 | 328 | {/* Pagination */} |
|
0 commit comments