Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/fresh-lines-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

SelectPanel: Ensure empty message live region reads from provided or default message
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export interface FilteredActionListProps
inputRef?: React.RefObject<HTMLInputElement>
className?: string
announcementsEnabled?: boolean
messageText?: {
title: string
description: string
}
Comment on lines +47 to +50
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just so TypeScript doesn't yell. Won't be needed once we remove the FF.

onSelectAllChange?: (checked: boolean) => void
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ export interface FilteredActionListProps
textInputProps?: Partial<Omit<TextInputProps, 'onChange'>>
inputRef?: React.RefObject<HTMLInputElement>
message?: React.ReactNode
messageText?: {
title: string
description: string
}
className?: string
announcementsEnabled?: boolean
fullScreenOnNarrow?: boolean
Expand All @@ -69,6 +73,7 @@ export function FilteredActionList({
groupMetadata,
showItemDividers,
message,
messageText,
className,
announcementsEnabled = true,
fullScreenOnNarrow,
Expand Down Expand Up @@ -155,7 +160,7 @@ export function FilteredActionList({
}
}, [items])

useAnnouncements(items, {current: listContainerElement}, inputRef, announcementsEnabled, loading)
useAnnouncements(items, {current: listContainerElement}, inputRef, announcementsEnabled, loading, messageText)
useScrollFlash(scrollContainerRef)

const handleSelectAllChange = useCallback(
Expand Down
5 changes: 3 additions & 2 deletions packages/react/src/FilteredActionList/useAnnouncements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const useAnnouncements = (
inputRef: React.RefObject<HTMLInputElement>,
enabled: boolean = true,
loading: boolean = false,
message?: {title: string; description: string},
) => {
const liveRegion = document.querySelector('live-region')

Expand Down Expand Up @@ -92,7 +93,7 @@ export const useAnnouncements = (
liveRegion?.clear() // clear previous announcements

if (items.length === 0 && !loading) {
announce('No matching items.', {delayMs})
announce(`${message?.title}. ${message?.description}`, {delayMs})
Copy link

Copilot AI Jul 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When message is undefined, this will announce 'undefined. undefined' to screen readers. Add a fallback: announce(message ? ${message.title}. ${message.description} : 'No matching items.', {delayMs})

Suggested change
announce(`${message?.title}. ${message?.description}`, {delayMs})
announce(message ? `${message.title}. ${message.description}` : 'No matching items.', {delayMs})

Copilot uses AI. Check for mistakes.
return
}

Expand All @@ -115,6 +116,6 @@ export const useAnnouncements = (
})
})
},
[announce, isFirstRender, items, listContainerRef, liveRegion, loading],
[announce, isFirstRender, items, listContainerRef, liveRegion, loading, message],
)
}
31 changes: 31 additions & 0 deletions packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -465,3 +465,34 @@ export const SelectPanelRepositionInsideDialog = () => {
</Dialog>
)
}

export const WithDefaultMessage = () => {
const [selected, setSelected] = useState<ItemInput[]>(items.slice(1, 3))
const [filter, setFilter] = useState('')
const filteredItems = items.filter(item => item.text.toLowerCase().startsWith(filter.toLowerCase()))

const [open, setOpen] = useState(false)

return (
<FormControl>
<FormControl.Label>Labels</FormControl.Label>
<SelectPanel
title="Select labels"
placeholder="Select labels" // button text when no items are selected
subtitle="Use labels to organize issues and pull requests"
renderAnchor={({children, ...anchorProps}) => (
<Button trailingAction={TriangleDownIcon} {...anchorProps} aria-haspopup="dialog">
{children}
</Button>
)}
open={open}
onOpenChange={setOpen}
items={filteredItems}
selected={selected}
onSelectedChange={setSelected}
onFilterChange={setFilter}
width="medium"
/>
</FormControl>
)
}
58 changes: 56 additions & 2 deletions packages/react/src/SelectPanel/SelectPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -751,7 +751,7 @@ for (const useModernActionList of [false, true]) {
jest.useRealTimers()
})

it('should announce when no results are available', async () => {
it('should announce default empty message when no results are available (no custom message is provided)', async () => {
jest.useFakeTimers()
const user = userEvent.setup({
advanceTimers: jest.advanceTimersByTime,
Expand All @@ -765,7 +765,61 @@ for (const useModernActionList of [false, true]) {

jest.runAllTimers()
await waitFor(async () => {
expect(getLiveRegion().getMessage('polite')).toBe('No matching items.')
expect(getLiveRegion().getMessage('polite')).toBe(
"You haven't created any items yet. Please add or create new items to populate the list.",
)
})
jest.useRealTimers()
})

it('should announce custom empty message when no results are available', async () => {
jest.useFakeTimers()
const user = userEvent.setup({
advanceTimers: jest.advanceTimersByTime,
})

function SelectPanelWithCustomEmptyMessage() {
const [filter, setFilter] = React.useState('')
const [open, setOpen] = React.useState(false)

return (
<ThemeProvider>
<SelectPanel
title="test title"
subtitle="test subtitle"
placeholder="Select items"
placeholderText="Filter items"
open={open}
items={[]}
onFilterChange={value => {
setFilter(value)
}}
filterValue={filter}
selected={[]}
onSelectedChange={() => {}}
onOpenChange={isOpen => {
setOpen(isOpen)
}}
message={{
title: 'Nothing found',
body: `There's nothing here.`,
variant: 'empty',
}}
/>
</ThemeProvider>
)
}

renderWithFlag(<SelectPanelWithCustomEmptyMessage />, useModernActionList)

await user.click(screen.getByText('Select items'))

await user.type(document.activeElement!, 'zero')
expect(screen.queryByRole('option')).toBeNull()

jest.runAllTimers()
await waitFor(async () => {
expect(getLiveRegion().getMessage('polite')).toBe(`Nothing found. There's nothing here.`)
})
jest.useRealTimers()
})
Expand Down
23 changes: 17 additions & 6 deletions packages/react/src/SelectPanel/SelectPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,14 @@ import type {ButtonProps, LinkButtonProps} from '../Button/types'
// we add a delay so that it does not interrupt default screen reader announcement and queues after it
const SHORT_DELAY_MS = 500
const LONG_DELAY_MS = 1000
const EMPTY_MESSAGE = {
title: "You haven't created any items yet",
description: 'Please add or create new items to populate the list.',
}

const DefaultEmptyMessage = (
<SelectPanelMessage variant="empty" title="You haven't created any items yet" key="empty-message">
Please add or create new items to populate the list.
<SelectPanelMessage variant="empty" title={EMPTY_MESSAGE.title} key="empty-message">
{EMPTY_MESSAGE.description}
</SelectPanelMessage>
)

Expand All @@ -53,7 +57,7 @@ async function announceLoading() {
}

const announceNoItems = debounce((message?: string) => {
announceText(message ?? 'No matching items.', LONG_DELAY_MS)
announceText(message ?? `${EMPTY_MESSAGE.title}. ${EMPTY_MESSAGE.description}`, LONG_DELAY_MS)
}, 250)

interface SelectPanelSingleSelection {
Expand Down Expand Up @@ -231,11 +235,11 @@ function Panel({
(node: HTMLElement | null) => {
setListContainerElement(node)
if (!node && needsNoItemsAnnouncement) {
announceNoItems()
if (!usingModernActionList) announceNoItems()
setNeedsNoItemsAnnouncement(false)
}
},
[needsNoItemsAnnouncement],
[needsNoItemsAnnouncement, usingModernActionList],
)

const onInputRefChanged = useCallback(
Expand Down Expand Up @@ -350,7 +354,7 @@ function Panel({
if (open) {
if (items.length === 0 && !(isLoading || loading)) {
// we need to wait for the listContainerElement to disappear before announcing no items, otherwise it will be interrupted
if (!listContainerElement || !usingModernActionList) {
if (!listContainerElement && !usingModernActionList) {
announceNoItems(message?.title)
} else {
setNeedsNoItemsAnnouncement(true)
Expand Down Expand Up @@ -821,6 +825,13 @@ function Panel({
// hack because the deprecated ActionList does not support this prop
{...{
message: getMessage(),
messageText: {
title: message?.title || EMPTY_MESSAGE.title,
description:
typeof message?.body === 'string'
? message.body
: EMPTY_MESSAGE.description || EMPTY_MESSAGE.description,
Copy link

Copilot AI Jul 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback logic EMPTY_MESSAGE.description || EMPTY_MESSAGE.description is redundant. This should likely be message.body || EMPTY_MESSAGE.description to properly fallback to the default when message.body is falsy.

Suggested change
: EMPTY_MESSAGE.description || EMPTY_MESSAGE.description,
: message.body || EMPTY_MESSAGE.description,

Copilot uses AI. Check for mistakes.
},
fullScreenOnNarrow: usingFullScreenOnNarrow,
}}
// inheriting height and maxHeight ensures that the FilteredActionList is never taller
Expand Down
Loading