diff --git a/.changeset/mighty-doors-beg.md b/.changeset/mighty-doors-beg.md
new file mode 100644
index 00000000000..d18f4855ea8
--- /dev/null
+++ b/.changeset/mighty-doors-beg.md
@@ -0,0 +1,5 @@
+---
+"@primer/react": minor
+---
+
+Select Panel: Add built-in "No matches" item (behind feature flag `primer_react_select_panel_with_modern_action_list`)
diff --git a/.changeset/small-melons-fail.md b/.changeset/small-melons-fail.md
new file mode 100644
index 00000000000..c2465b10950
--- /dev/null
+++ b/.changeset/small-melons-fail.md
@@ -0,0 +1,5 @@
+---
+"@primer/react": patch
+---
+
+SelectPanel: Add announcements for screen readers (behind feature flag `primer_react_select_panel_with_modern_action_list`)
diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-colorblind-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-colorblind-linux.png
new file mode 100644
index 00000000000..fab4fd093f7
Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-colorblind-linux.png differ
diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-colorblind-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-colorblind-modern-action-list--true-linux.png
new file mode 100644
index 00000000000..fab4fd093f7
Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-colorblind-modern-action-list--true-linux.png differ
diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-dimmed-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-dimmed-linux.png
new file mode 100644
index 00000000000..865ac022d45
Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-dimmed-linux.png differ
diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-dimmed-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-dimmed-modern-action-list--true-linux.png
new file mode 100644
index 00000000000..865ac022d45
Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-dimmed-modern-action-list--true-linux.png differ
diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-high-contrast-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-high-contrast-linux.png
new file mode 100644
index 00000000000..14caf14e856
Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-high-contrast-linux.png differ
diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-high-contrast-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-high-contrast-modern-action-list--true-linux.png
new file mode 100644
index 00000000000..14caf14e856
Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-high-contrast-modern-action-list--true-linux.png differ
diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-linux.png
new file mode 100644
index 00000000000..fab4fd093f7
Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-linux.png differ
diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-modern-action-list--true-linux.png
new file mode 100644
index 00000000000..fab4fd093f7
Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-modern-action-list--true-linux.png differ
diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-tritanopia-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-tritanopia-linux.png
new file mode 100644
index 00000000000..fab4fd093f7
Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-tritanopia-linux.png differ
diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-tritanopia-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-tritanopia-modern-action-list--true-linux.png
new file mode 100644
index 00000000000..fab4fd093f7
Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-dark-tritanopia-modern-action-list--true-linux.png differ
diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-colorblind-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-colorblind-linux.png
new file mode 100644
index 00000000000..0ae224d9a54
Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-colorblind-linux.png differ
diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-colorblind-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-colorblind-modern-action-list--true-linux.png
new file mode 100644
index 00000000000..0ae224d9a54
Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-colorblind-modern-action-list--true-linux.png differ
diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-high-contrast-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-high-contrast-linux.png
new file mode 100644
index 00000000000..0b023b3f496
Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-high-contrast-linux.png differ
diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-high-contrast-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-high-contrast-modern-action-list--true-linux.png
new file mode 100644
index 00000000000..0b023b3f496
Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-high-contrast-modern-action-list--true-linux.png differ
diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-linux.png
new file mode 100644
index 00000000000..0ae224d9a54
Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-linux.png differ
diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-modern-action-list--true-linux.png
new file mode 100644
index 00000000000..0ae224d9a54
Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-modern-action-list--true-linux.png differ
diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-tritanopia-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-tritanopia-linux.png
new file mode 100644
index 00000000000..0ae224d9a54
Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-tritanopia-linux.png differ
diff --git a/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-tritanopia-modern-action-list--true-linux.png b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-tritanopia-modern-action-list--true-linux.png
new file mode 100644
index 00000000000..0ae224d9a54
Binary files /dev/null and b/.playwright/snapshots/components/SelectPanel.test.ts-snapshots/SelectPanel-No-matches-light-tritanopia-modern-action-list--true-linux.png differ
diff --git a/e2e/components/SelectPanel.test.ts b/e2e/components/SelectPanel.test.ts
index e4f61864b17..45ad02fe90f 100644
--- a/e2e/components/SelectPanel.test.ts
+++ b/e2e/components/SelectPanel.test.ts
@@ -32,6 +32,10 @@ const scenarios = matrix({
id: 'components-selectpanel-examples--height-initial-with-underflowing-items-after-fetch',
name: 'Height Initial with Underflowing Items After Fetch',
},
+ {
+ id: 'components-selectpanel-examples--no-matches',
+ name: 'No matches',
+ },
],
})
diff --git a/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx
index ae791e63909..7c447544105 100644
--- a/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx
+++ b/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx
@@ -20,6 +20,8 @@ import type {SxProp} from '../sx'
import {isValidElementType} from 'react-is'
import type {RenderItemFn} from '../deprecated/ActionList/List'
+import {CircleSlashIcon} from '@primer/octicons-react'
+import {useAnnouncements} from './useAnnouncements'
const menuScrollMargins: ScrollIntoViewOptions = {startMargin: 0, endMargin: 8}
@@ -114,6 +116,7 @@ export function FilteredActionList({
}, [items])
useScrollFlash(scrollContainerRef)
+ useAnnouncements(items, listContainerRef, inputRef)
function getItemListForEachGroup(groupId: string) {
const itemsInGroup = []
@@ -151,7 +154,14 @@ export function FilteredActionList({
) : (
-
+
{groupMetadata?.length
? groupMetadata.map((group, index) => {
return (
@@ -168,6 +178,15 @@ export function FilteredActionList({
: items.map((item, index) => {
return
})}
+
+ {items.length === 0 ? (
+
+
+
+
+ No matches
+
+ ) : undefined}
)}
diff --git a/packages/react/src/FilteredActionList/useAnnouncements.tsx b/packages/react/src/FilteredActionList/useAnnouncements.tsx
new file mode 100644
index 00000000000..8b419d92584
--- /dev/null
+++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx
@@ -0,0 +1,97 @@
+// Announcements for FilteredActionList (and SelectPanel) based
+// on https://github.com/github/multi-select-user-testing
+
+import {announce} from '@primer/live-region-element'
+import {useEffect, useRef} from 'react'
+import type {FilteredActionListProps} from './FilteredActionListEntry'
+
+// we add a delay so that it does not interrupt default screen reader announcement and queues after it
+const delayMs = 500
+
+const useFirstRender = () => {
+ const firstRender = useRef(true)
+ useEffect(() => {
+ firstRender.current = false
+ }, [])
+ return firstRender.current
+}
+
+const getItemWithActiveDescendant = (
+ listRef: React.RefObject,
+ items: FilteredActionListProps['items'],
+) => {
+ const listElement = listRef.current
+ const activeItemElement = listElement?.querySelector('[data-is-active-descendant]')
+
+ if (!listElement || !activeItemElement?.textContent) return
+
+ const optionElements = listElement.querySelectorAll('[role="option"]')
+
+ const index = Array.from(optionElements).indexOf(activeItemElement)
+ const activeItem = items[index]
+
+ const text = activeItem.text
+ const selected = activeItem.selected
+
+ return {index, text, selected}
+}
+
+export const useAnnouncements = (
+ items: FilteredActionListProps['items'],
+ listContainerRef: React.RefObject,
+ inputRef: React.RefObject,
+) => {
+ useEffect(
+ function announceInitialFocus() {
+ const focusHandler = () => {
+ // give @primer/behaviors a moment to apply active-descendant
+ window.requestAnimationFrame(() => {
+ const activeItem = getItemWithActiveDescendant(listContainerRef, items)
+ if (!activeItem) return
+ const {index, text, selected} = activeItem
+
+ const announcementText = [
+ `Focus on filter text box and list of labels`,
+ `Focused item: ${text}`,
+ `${selected ? 'selected' : 'not selected'}`,
+ `${index + 1} of ${items.length}`,
+ ].join(', ')
+ announce(announcementText, {delayMs})
+ })
+ }
+
+ const inputElement = inputRef.current
+ inputElement?.addEventListener('focus', focusHandler)
+ return () => inputElement?.removeEventListener('focus', focusHandler)
+ },
+ [listContainerRef, inputRef, items],
+ )
+
+ const isFirstRender = useFirstRender()
+ useEffect(
+ function announceListUpdates() {
+ if (isFirstRender) return // ignore on first render as announceInitialFocus will also announce
+
+ if (items.length === 0) {
+ announce('No matching items.', {delayMs})
+ return
+ }
+
+ // give @primer/behaviors a moment to update active-descendant
+ window.requestAnimationFrame(() => {
+ const activeItem = getItemWithActiveDescendant(listContainerRef, items)
+ if (!activeItem) return
+ const {index, text, selected} = activeItem
+
+ const announcementText = [
+ `List updated`,
+ `Focused item: ${text}`,
+ `${selected ? 'selected' : 'not selected'}`,
+ `${index} of ${items.length}`,
+ ].join(', ')
+ announce(announcementText, {delayMs})
+ })
+ },
+ [listContainerRef, inputRef, items, isFirstRender],
+ )
+}
diff --git a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx
index 248432e0f9a..6570019e3d6 100644
--- a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx
+++ b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx
@@ -309,3 +309,32 @@ export const CustomItemRenderer = () => {
>
)
}
+
+export const NoMatches = () => {
+ const [selected, setSelected] = React.useState([items[0], items[1]])
+ const [filter, setFilter] = React.useState('no-matches-for-this')
+ const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))
+ const [open, setOpen] = useState(false)
+
+ return (
+ <>
+ No matches
+ (
+
+ )}
+ open={open}
+ onOpenChange={setOpen}
+ items={filteredItems}
+ selected={selected}
+ onSelectedChange={setSelected}
+ filterValue={filter}
+ onFilterChange={setFilter}
+ overlayProps={{width: 'medium'}}
+ />
+ >
+ )
+}
diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx
index 7d6adfbb3ae..2e8d04a17f4 100644
--- a/packages/react/src/SelectPanel/SelectPanel.test.tsx
+++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx
@@ -1,10 +1,11 @@
-import {render, screen} from '@testing-library/react'
+import {render, screen, waitFor} from '@testing-library/react'
import React from 'react'
import {SelectPanel, type SelectPanelProps} from '../SelectPanel'
import type {ItemInput, GroupedListProps} from '../deprecated/ActionList/List'
import {userEvent} from '@testing-library/user-event'
import ThemeProvider from '../ThemeProvider'
import {FeatureFlags} from '../FeatureFlags'
+import {getLiveRegion} from '../utils/testing'
const renderWithFlag = (children: React.ReactNode, flag: boolean) => {
return render(
@@ -336,39 +337,39 @@ for (const useModernActionList of [false, true]) {
})
})
- describe('filtering', () => {
- function FilterableSelectPanel() {
- const [selected, setSelected] = React.useState([])
- const [filter, setFilter] = React.useState('')
- const [open, setOpen] = React.useState(false)
-
- const onSelectedChange = (selected: SelectPanelProps['items']) => {
- setSelected(selected)
- }
+ function FilterableSelectPanel() {
+ const [selected, setSelected] = React.useState([])
+ const [filter, setFilter] = React.useState('')
+ const [open, setOpen] = React.useState(false)
- return (
-
- item.text?.includes(filter))}
- placeholder="Select items"
- placeholderText="Filter items"
- selected={selected}
- onSelectedChange={onSelectedChange}
- filterValue={filter}
- onFilterChange={value => {
- setFilter(value)
- }}
- open={open}
- onOpenChange={isOpen => {
- setOpen(isOpen)
- }}
- />
-
- )
+ const onSelectedChange = (selected: SelectPanelProps['items']) => {
+ setSelected(selected)
}
+ return (
+
+ item.text?.includes(filter))}
+ placeholder="Select items"
+ placeholderText="Filter items"
+ selected={selected}
+ onSelectedChange={onSelectedChange}
+ filterValue={filter}
+ onFilterChange={value => {
+ setFilter(value)
+ }}
+ open={open}
+ onOpenChange={isOpen => {
+ setOpen(isOpen)
+ }}
+ />
+
+ )
+ }
+
+ describe('filtering', () => {
it('should filter the list of items when the user types into the input', async () => {
const user = userEvent.setup()
@@ -381,10 +382,68 @@ for (const useModernActionList of [false, true]) {
await user.type(document.activeElement!, 'two')
expect(screen.getAllByRole('option')).toHaveLength(1)
})
+ })
+
+ describe('screen reader announcements', () => {
+ // this is only implemented with the feature flag
+ if (!useModernActionList) return
+
+ it('should announce initial focused item', async () => {
+ const user = userEvent.setup()
+ renderWithFlag(, useModernActionList)
+
+ await user.click(screen.getByText('Select items'))
+ expect(screen.getByLabelText('Filter items')).toHaveFocus()
+
+ // we wait because announcement is intentionally updated after a timeout to not interrupt user input
+ await waitFor(async () => {
+ expect(getLiveRegion().getMessage('polite')).toBe(
+ 'Focus on filter text box and list of labels, Focused item: item one, not selected, 1 of 3',
+ )
+ })
+ })
+
+ it('should announce filtered results', async () => {
+ const user = userEvent.setup()
+ renderWithFlag(, useModernActionList)
+
+ await user.click(screen.getByText('Select items'))
+ await user.type(document.activeElement!, 'o')
+ expect(screen.getAllByRole('option')).toHaveLength(2)
+
+ await waitFor(
+ async () => {
+ expect(getLiveRegion().getMessage('polite')).toBe(
+ 'List updated, Focused item: item one, not selected, 1 of 2',
+ )
+ },
+ {timeout: 3000}, // increased timeout because we don't want the test to compare with previous announcement
+ )
+
+ await user.type(document.activeElement!, 'ne') // now: one
+ expect(screen.getAllByRole('option')).toHaveLength(1)
+
+ await waitFor(async () => {
+ expect(getLiveRegion().getMessage('polite')).toBe(
+ 'List updated, Focused item: item one, not selected, 1 of 1',
+ )
+ })
+ })
- it.todo('should announce the number of results')
+ it('should announce when no results are available', async () => {
+ const user = userEvent.setup()
+ renderWithFlag(, useModernActionList)
- it.todo('should announce when no results are available')
+ await user.click(screen.getByText('Select items'))
+
+ await user.type(document.activeElement!, 'zero')
+ expect(screen.queryByRole('option')).toBeNull()
+ expect(screen.getByText('No matches')).toBeVisible()
+
+ await waitFor(async () => {
+ expect(getLiveRegion().getMessage('polite')).toBe('No matching items.')
+ })
+ })
})
describe('with footer', () => {
diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx
index edf35fafee6..8c0aea638fd 100644
--- a/packages/react/src/SelectPanel/SelectPanel.tsx
+++ b/packages/react/src/SelectPanel/SelectPanel.tsx
@@ -17,6 +17,7 @@ import type {FocusZoneHookSettings} from '../hooks/useFocusZone'
import {useId} from '../hooks/useId'
import {useProvidedStateOrCreate} from '../hooks/useProvidedStateOrCreate'
import {LiveRegion, LiveRegionOutlet, Message} from '../internal/components/LiveRegion'
+import {useFeatureFlag} from '../FeatureFlags'
interface SelectPanelSingleSelection {
selected: ItemInput | undefined
@@ -174,6 +175,8 @@ export function SelectPanel({
}
}, [inputLabel, textInputProps])
+ const usingModernActionList = useFeatureFlag('primer_react_select_panel_with_modern_action_list')
+
return (
-
+ {usingModernActionList ? null : (
+
+ )}
+
diff --git a/packages/react/src/live-region/__tests__/Announce.test.tsx b/packages/react/src/live-region/__tests__/Announce.test.tsx
index 5586297a8dd..b3364bf9c2b 100644
--- a/packages/react/src/live-region/__tests__/Announce.test.tsx
+++ b/packages/react/src/live-region/__tests__/Announce.test.tsx
@@ -1,15 +1,7 @@
import {render, screen} from '@testing-library/react'
import React from 'react'
-import type {LiveRegionElement} from '@primer/live-region-element'
import {Announce} from '../Announce'
-
-function getLiveRegion(): LiveRegionElement {
- const liveRegion = document.querySelector('live-region')
- if (liveRegion) {
- return liveRegion as LiveRegionElement
- }
- throw new Error('No live-region found')
-}
+import {getLiveRegion} from '../../utils/testing'
describe('Announce', () => {
beforeEach(() => {
diff --git a/packages/react/src/live-region/__tests__/AriaAlert.test.tsx b/packages/react/src/live-region/__tests__/AriaAlert.test.tsx
index e51e4558d44..91c4d83731e 100644
--- a/packages/react/src/live-region/__tests__/AriaAlert.test.tsx
+++ b/packages/react/src/live-region/__tests__/AriaAlert.test.tsx
@@ -1,15 +1,7 @@
import {render, screen} from '@testing-library/react'
import React from 'react'
-import type {LiveRegionElement} from '@primer/live-region-element'
import {AriaAlert} from '../AriaAlert'
-
-function getLiveRegion(): LiveRegionElement {
- const liveRegion = document.querySelector('live-region')
- if (liveRegion) {
- return liveRegion as LiveRegionElement
- }
- throw new Error('No live-region found')
-}
+import {getLiveRegion} from '../../utils/testing'
describe('AriaAlert', () => {
beforeEach(() => {
diff --git a/packages/react/src/live-region/__tests__/AriaStatus.test.tsx b/packages/react/src/live-region/__tests__/AriaStatus.test.tsx
index 29bed2c78fb..16465c03a5c 100644
--- a/packages/react/src/live-region/__tests__/AriaStatus.test.tsx
+++ b/packages/react/src/live-region/__tests__/AriaStatus.test.tsx
@@ -1,16 +1,8 @@
import {render, screen} from '@testing-library/react'
import React from 'react'
-import type {LiveRegionElement} from '@primer/live-region-element'
import {AriaStatus} from '../AriaStatus'
import {userEvent} from '@testing-library/user-event'
-
-function getLiveRegion(): LiveRegionElement {
- const liveRegion = document.querySelector('live-region')
- if (liveRegion) {
- return liveRegion as LiveRegionElement
- }
- throw new Error('No live-region found')
-}
+import {getLiveRegion} from '../../utils/testing'
describe('AriaStatus', () => {
beforeEach(() => {
diff --git a/packages/react/src/utils/testing.tsx b/packages/react/src/utils/testing.tsx
index 5f013cd9624..5f5f1caa257 100644
--- a/packages/react/src/utils/testing.tsx
+++ b/packages/react/src/utils/testing.tsx
@@ -7,6 +7,7 @@ import axe from 'axe-core'
import customRules from '@github/axe-github'
import {ThemeProvider} from '..'
import {default as defaultTheme} from '../theme'
+import type {LiveRegionElement} from '@primer/live-region-element'
type ComputedStyles = Record>
@@ -270,3 +271,11 @@ export function checkStoriesForAxeViolations(name: string, storyDir?: string) {
})
})
}
+
+export function getLiveRegion(): LiveRegionElement {
+ const liveRegion = document.querySelector('live-region')
+ if (liveRegion) {
+ return liveRegion as LiveRegionElement
+ }
+ throw new Error('No live-region found')
+}