diff --git a/.changeset/fresh-lines-guess.md b/.changeset/fresh-lines-guess.md new file mode 100644 index 00000000000..623e1474706 --- /dev/null +++ b/.changeset/fresh-lines-guess.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +SelectPanel: Ensure empty message live region reads from provided or default message diff --git a/packages/react/src/FilteredActionList/FilteredActionListWithDeprecatedActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionListWithDeprecatedActionList.tsx index cec49c9ee8f..c32abcd618a 100644 --- a/packages/react/src/FilteredActionList/FilteredActionListWithDeprecatedActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionListWithDeprecatedActionList.tsx @@ -44,6 +44,10 @@ export interface FilteredActionListProps inputRef?: React.RefObject className?: string announcementsEnabled?: boolean + messageText?: { + title: string + description: string + } onSelectAllChange?: (checked: boolean) => void } diff --git a/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx b/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx index 1568cad1032..4fb78de0e56 100644 --- a/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx +++ b/packages/react/src/FilteredActionList/FilteredActionListWithModernActionList.tsx @@ -43,6 +43,10 @@ export interface FilteredActionListProps textInputProps?: Partial> inputRef?: React.RefObject message?: React.ReactNode + messageText?: { + title: string + description: string + } className?: string announcementsEnabled?: boolean fullScreenOnNarrow?: boolean @@ -69,6 +73,7 @@ export function FilteredActionList({ groupMetadata, showItemDividers, message, + messageText, className, announcementsEnabled = true, fullScreenOnNarrow, @@ -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( diff --git a/packages/react/src/FilteredActionList/useAnnouncements.tsx b/packages/react/src/FilteredActionList/useAnnouncements.tsx index 9b3b82ed539..d3070c820f8 100644 --- a/packages/react/src/FilteredActionList/useAnnouncements.tsx +++ b/packages/react/src/FilteredActionList/useAnnouncements.tsx @@ -43,6 +43,7 @@ export const useAnnouncements = ( inputRef: React.RefObject, enabled: boolean = true, loading: boolean = false, + message?: {title: string; description: string}, ) => { const liveRegion = document.querySelector('live-region') @@ -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}) return } @@ -115,6 +116,6 @@ export const useAnnouncements = ( }) }) }, - [announce, isFirstRender, items, listContainerRef, liveRegion, loading], + [announce, isFirstRender, items, listContainerRef, liveRegion, loading, message], ) } diff --git a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx index b338657a1ac..a9641a9abc8 100644 --- a/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.examples.stories.tsx @@ -465,3 +465,34 @@ export const SelectPanelRepositionInsideDialog = () => { ) } + +export const WithDefaultMessage = () => { + const [selected, setSelected] = useState(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 ( + + Labels + ( + + )} + open={open} + onOpenChange={setOpen} + items={filteredItems} + selected={selected} + onSelectedChange={setSelected} + onFilterChange={setFilter} + width="medium" + /> + + ) +} diff --git a/packages/react/src/SelectPanel/SelectPanel.test.tsx b/packages/react/src/SelectPanel/SelectPanel.test.tsx index e48731ac84b..b1abc653f73 100644 --- a/packages/react/src/SelectPanel/SelectPanel.test.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.test.tsx @@ -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, @@ -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 ( + + { + setFilter(value) + }} + filterValue={filter} + selected={[]} + onSelectedChange={() => {}} + onOpenChange={isOpen => { + setOpen(isOpen) + }} + message={{ + title: 'Nothing found', + body: `There's nothing here.`, + variant: 'empty', + }} + /> + + ) + } + + renderWithFlag(, 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() }) diff --git a/packages/react/src/SelectPanel/SelectPanel.tsx b/packages/react/src/SelectPanel/SelectPanel.tsx index a83e4495773..00e6f7a4b71 100644 --- a/packages/react/src/SelectPanel/SelectPanel.tsx +++ b/packages/react/src/SelectPanel/SelectPanel.tsx @@ -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 = ( - - Please add or create new items to populate the list. + + {EMPTY_MESSAGE.description} ) @@ -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 { @@ -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( @@ -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) @@ -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, + }, fullScreenOnNarrow: usingFullScreenOnNarrow, }} // inheriting height and maxHeight ensures that the FilteredActionList is never taller