diff --git a/.storybook/snapshots/__snapshots__/mapboxmap--custom-pin.png b/.storybook/snapshots/__snapshots__/mapboxmap--custom-pin.png index b0c5951b..6eb9c856 100644 Binary files a/.storybook/snapshots/__snapshots__/mapboxmap--custom-pin.png and b/.storybook/snapshots/__snapshots__/mapboxmap--custom-pin.png differ diff --git a/.storybook/snapshots/__snapshots__/mapboxmap--custom-render-pin.png b/.storybook/snapshots/__snapshots__/mapboxmap--custom-render-pin.png index 1b1ac97c..bb670b51 100644 Binary files a/.storybook/snapshots/__snapshots__/mapboxmap--custom-render-pin.png and b/.storybook/snapshots/__snapshots__/mapboxmap--custom-render-pin.png differ diff --git a/locales/en-GB/search-ui-react.json b/locales/en-GB/search-ui-react.json index 84f76ab2..b4efe4b0 100644 --- a/locales/en-GB/search-ui-react.json +++ b/locales/en-GB/search-ui-react.json @@ -40,7 +40,7 @@ "removeFilter": "Remove \"{{displayName}}\" filter", "resultPreview": "result preview: {{value}}", "resultPreviewsFound_one": "{{count}} result preview found.", - "resultPreviewsFound_other": "{{count}} recent previews found.", + "resultPreviewsFound_other": "{{count}} result previews found.", "resultsCountText_one": "{{count}} Result", "resultsCountText_other": "{{count}} Results", "resultsCountWithPaginationText": "{{paginateStart}} - {{paginateEnd}} of {{resultsCount}} Results", diff --git a/locales/en/search-ui-react.json b/locales/en/search-ui-react.json index 2230b0fd..87707603 100644 --- a/locales/en/search-ui-react.json +++ b/locales/en/search-ui-react.json @@ -44,7 +44,7 @@ "removeFilter": "Remove \"{{displayName}}\" filter", "resultPreview": "result preview: {{value}}", "resultPreviewsFound_one": "{{count}} result preview found.", - "resultPreviewsFound_other": "{{count}} recent previews found.", + "resultPreviewsFound_other": "{{count}} result previews found.", "resultsCountText_one": "{{count}} Result", "resultsCountText_other": "{{count}} Results", "resultsCountWithPaginationText": "{{paginateStart}} - {{paginateEnd}} of {{resultsCount}} Results", diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx index 0ff3ac17..b6e84fa3 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -21,6 +21,7 @@ import { useLayoutEffect } from '../../hooks/useLayoutEffect'; import { useId } from '../../hooks/useId'; const useRootClose = typeof useRootClosePkg === 'function' ? useRootClosePkg : useRootClosePkg['default']; +const resultAnnouncementDelayMs = 800; interface DropdownItemData { value: string, @@ -68,8 +69,11 @@ export function Dropdown(props: PropsWithChildren): React.JSX.Ele const containerRef = useRef(null); const screenReaderUUID = useId('dropdown'); const dropdownListUUID = useId('dropdown-list'); - const [screenReaderKey, setScreenReaderKey] = useState(0); const [hasTyped, setHasTyped] = useState(false); + const [isNavigatingOptions, setIsNavigatingOptions] = useState(false); + const [instructionsAnnouncement, setInstructionsAnnouncement] = useState(''); + const hasAnnouncedInstructionsRef = useRef(false); + const wasActiveRef = useRef(false); const [childrenWithDropdownItemsTransformed, items] = useMemo(() => { return getTransformedChildrenAndItemData(children); }, [children]); @@ -81,7 +85,6 @@ export function Dropdown(props: PropsWithChildren): React.JSX.Ele items, lastTypedOrSubmittedValue, setValue, - setScreenReaderKey, alwaysSelectOption ); const { focusedIndex, focusedItemData, updateFocusedItem } = focusContext; @@ -93,12 +96,26 @@ export function Dropdown(props: PropsWithChildren): React.JSX.Ele focusedItemData, screenReaderUUID, dropdownListUUID, + items.length > 0, setHasTyped, + setIsNavigatingOptions, onToggle, onSelect ); const { toggleDropdown, isActive } = dropdownContext; + useEffect(() => { + if (isActive && !wasActiveRef.current && !hasAnnouncedInstructionsRef.current) { + setInstructionsAnnouncement( + screenReaderInstructions ?? t('dropDownScreenReaderInstructions') + ); + hasAnnouncedInstructionsRef.current = true; + } else if (!isActive) { + setInstructionsAnnouncement(''); + } + wasActiveRef.current = isActive; + }, [isActive, screenReaderInstructions, t]); + useLayoutEffect(() => { if (parentQuery !== undefined && parentQuery !== lastTypedOrSubmittedValue) { setLastTypedOrSubmittedValue(parentQuery); @@ -122,6 +139,9 @@ export function Dropdown(props: PropsWithChildren): React.JSX.Ele if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); + setIsNavigatingOptions(true); + } else { + setIsNavigatingOptions(false); } if (e.key === 'ArrowDown') { @@ -144,6 +164,9 @@ export function Dropdown(props: PropsWithChildren): React.JSX.Ele } } + const resultAnnouncement = isActive && !isNavigatingOptions && (hasTyped || items.length || value) + ? screenReaderText + : ''; return (
@@ -154,11 +177,12 @@ export function Dropdown(props: PropsWithChildren): React.JSX.Ele +
+ {instructionsAnnouncement} +
); @@ -179,7 +203,6 @@ function useFocusContextInstance( items: DropdownItemData[], lastTypedOrSubmittedValue: string, setValue: (newValue: string) => void, - setScreenReaderKey: React.Dispatch>, alwaysSelectOption: boolean ): FocusContextType { const [focusedIndex, setFocusedIndex] = useState(-1); @@ -208,11 +231,9 @@ function useFocusContextInstance( if (alwaysSelectOption && numItems !== 0) { setFocusedIndex(0); setFocusedItemData(items[0].itemData); - setScreenReaderKey(prev => prev + 1); } else { setFocusedIndex(-1); setFocusedItemData(undefined); - setScreenReaderKey(prev => prev + 1); } } else if (updatedFocusedIndex < -1) { const loopedAroundIndex = (numItems + updatedFocusedIndex + 1) % numItems; @@ -243,7 +264,9 @@ function useDropdownContextInstance( focusedItemData: Record | undefined, screenReaderUUID: string | undefined, dropdownListUUID: string | undefined, + hasPopup: boolean, setHasTyped: (hasTyped: boolean) => void, + setIsNavigatingOptions: (isNavigating: boolean) => void, onToggle?: ( isActive: boolean, prevValue: string, @@ -255,6 +278,7 @@ function useDropdownContextInstance( ): DropdownContextType { const [isActive, _toggleDropdown] = useState(false); const toggleDropdown = (willBeOpen: boolean) => { + setIsNavigatingOptions(false); if (!willBeOpen) { setHasTyped(false); } @@ -263,6 +287,7 @@ function useDropdownContextInstance( }; return { isActive, + isExpanded: isActive && hasPopup, toggleDropdown, onSelect, screenReaderUUID, diff --git a/src/components/Dropdown/DropdownContext.ts b/src/components/Dropdown/DropdownContext.ts index f496807a..efb6a34d 100644 --- a/src/components/Dropdown/DropdownContext.ts +++ b/src/components/Dropdown/DropdownContext.ts @@ -4,7 +4,10 @@ import { createContext, useContext } from 'react'; * The Context responsible for the Dropdown state. */ export type DropdownContextType = { + /** Whether the input box is active */ isActive: boolean, + /** Whether the options are loaded and present */ + isExpanded: boolean, screenReaderUUID?: string, dropdownListUUID?: string, toggleDropdown: (visible: boolean) => void, @@ -19,4 +22,4 @@ export function useDropdownContext(): DropdownContextType { throw new Error('Tried to use DropdownContext when none exists.'); } return dropdownContextInstance; -} \ No newline at end of file +} diff --git a/src/components/Dropdown/DropdownInput.tsx b/src/components/Dropdown/DropdownInput.tsx index a34e02f2..23e333c7 100644 --- a/src/components/Dropdown/DropdownInput.tsx +++ b/src/components/Dropdown/DropdownInput.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEvent, KeyboardEvent, useCallback, useRef, useState } from 'react'; +import React, { ChangeEvent, KeyboardEvent, useCallback, useRef } from 'react'; import { useDropdownContext } from './DropdownContext'; import { useFocusContext, FocusedItemData } from './FocusContext'; import { generateDropdownId } from './generateDropdownId'; @@ -33,7 +33,13 @@ export function DropdownInput(props: { } = props; const inputRef = useRef(null); - const { toggleDropdown, onSelect, screenReaderUUID, dropdownListUUID, isActive } = useDropdownContext(); + const { + toggleDropdown, + onSelect, + screenReaderUUID, + dropdownListUUID, + isExpanded + } = useDropdownContext(); const { value = '', setLastTypedOrSubmittedValue } = useInputContext(); const { focusedIndex = -1, @@ -41,12 +47,9 @@ export function DropdownInput(props: { focusedValue, updateFocusedItem } = useFocusContext(); - const [isTyping, setIsTyping] = useState(true); - const describedBy = [screenReaderUUID, ariaDescribedBy].filter(Boolean).join(' ') || undefined; const resolvedAriaLabel = ariaLabelledBy ? undefined : ariaLabel; const handleChange = useCallback((e: ChangeEvent) => { - setIsTyping(true); toggleDropdown(true); onChange?.(e.target.value); updateFocusedItem(-1, e.target.value); @@ -54,18 +57,16 @@ export function DropdownInput(props: { }, [onChange, setLastTypedOrSubmittedValue, toggleDropdown, updateFocusedItem]); const handleKeyDown = useCallback((e: KeyboardEvent) => { - if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Tab') { - setIsTyping(false); - } if (e.key === 'Enter' && (!submitCriteria || submitCriteria(focusedIndex))) { - updateFocusedItem(focusedIndex); + const submittedValue = focusedIndex >= 0 ? focusedValue ?? value : value; toggleDropdown(false); inputRef.current?.blur(); - onSubmit?.(value, focusedIndex, focusedItemData); + setLastTypedOrSubmittedValue(submittedValue); + onSubmit?.(submittedValue, focusedIndex, focusedItemData); if (focusedIndex >= 0) { - onSelect?.(value, focusedIndex, focusedItemData); + onSelect?.(submittedValue, focusedIndex, focusedItemData); } - updateFocusedItem(-1, focusedValue ?? undefined); + updateFocusedItem(-1, submittedValue); } }, [ focusedIndex, @@ -73,6 +74,7 @@ export function DropdownInput(props: { focusedItemData, onSelect, onSubmit, + setLastTypedOrSubmittedValue, submitCriteria, toggleDropdown, updateFocusedItem, @@ -96,17 +98,17 @@ export function DropdownInput(props: { onFocus={handleFocus} id={inputId ?? generateDropdownId(screenReaderUUID, -1)} autoComplete='off' - aria-describedby={describedBy} + aria-describedby={ariaDescribedBy} aria-activedescendant={ - !isTyping ? generateDropdownId(screenReaderUUID, focusedIndex) : undefined + focusedIndex >= 0 ? generateDropdownId(screenReaderUUID, focusedIndex) : undefined } aria-label={resolvedAriaLabel} aria-labelledby={ariaLabelledBy} aria-autocomplete="list" role="combobox" aria-controls={dropdownListUUID} - aria-expanded={isActive ? 'true' : 'false'} + aria-expanded={isExpanded ? 'true' : 'false'} aria-haspopup="listbox" /> ); -} \ No newline at end of file +} diff --git a/src/components/Dropdown/DropdownItem.tsx b/src/components/Dropdown/DropdownItem.tsx index 7816a271..5891f0ed 100644 --- a/src/components/Dropdown/DropdownItem.tsx +++ b/src/components/Dropdown/DropdownItem.tsx @@ -50,6 +50,9 @@ export function DropdownItemWithIndex(props: DropdownItemProps & { index: number const { setValue, setLastTypedOrSubmittedValue } = useInputContext(); const isFocused = focusedIndex === index; + const optionId = generateDropdownId(screenReaderUUID, index); + const resolvedAriaLabel = typeof ariaLabel === 'function' ? ariaLabel(value) : ariaLabel; + const ariaLabelId = resolvedAriaLabel ? `${optionId}-label` : undefined; const handleClick = useCallback(() => { toggleDropdown(false); @@ -70,25 +73,25 @@ export function DropdownItemWithIndex(props: DropdownItemProps & { index: number value ]); - const baseButtonClasses = 'bg-transparent border-0 p-0 m-0 font-inherit text-inherit text-left ' - + 'cursor-pointer w-full self-stretch box-border'; + const baseOptionClasses = 'text-left cursor-pointer w-full self-stretch box-border'; const combinedClassName = twMerge( - baseButtonClasses, + baseOptionClasses, isFocused ? focusedClassName ?? '' : className ?? '' ); return ( - + {resolvedAriaLabel && {resolvedAriaLabel}} +
+ {children} +
+ ); } diff --git a/src/components/FilterSearch.tsx b/src/components/FilterSearch.tsx index ac741538..44040986 100644 --- a/src/components/FilterSearch.tsx +++ b/src/components/FilterSearch.tsx @@ -333,7 +333,7 @@ export function FilterSearch({
{section.label && @@ -348,6 +348,7 @@ export function FilterSearch({ focusedClassName={cssClasses.focusedOption} value={result.value} itemData={itemDataMatrix[sectionIndex][index]} + ariaLabel={result.value} > {renderAutocompleteResult(result, cssClasses)} @@ -399,7 +400,7 @@ export function FilterSearch({ )} { + setRenderedAnnouncement(''); + if (!announcementText) { + return; + } + + const timeoutId = setTimeout(() => { + setRenderedAnnouncement(announcementText); + }, announcementDelayMs); + return () => clearTimeout(timeoutId); + }, [announcementDelayMs, announcementText]); return ( - <> -
- {instructions} -
-
- {announcementText} -
- +
+ {renderedAnnouncement} +
); } diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 50237143..01bf9b7a 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -460,7 +460,7 @@ export function SearchBar({ {renderRecentSearches()} {renderQuerySuggestions()} {entityPreviews && ( -
+
{showEntityPreviewsDivider && diff --git a/tests/components/Dropdown.test.tsx b/tests/components/Dropdown.test.tsx index 9f11852c..fe088aac 100644 --- a/tests/components/Dropdown.test.tsx +++ b/tests/components/Dropdown.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Dropdown, DropdownProps } from '../../src/components/Dropdown/Dropdown'; import { DropdownInput } from '../../src/components/Dropdown/DropdownInput'; @@ -59,7 +59,68 @@ describe('Dropdown', () => { expect(mockedOnToggleFn).toBeCalledWith(true, '', '', -1, undefined); }); - it('handles arrowkey navigation properly and focuses on the option and input text', async () => { + it('uses a stable live region and re-announces options when reopened', async () => { + render( +
+ + + + item1 + + + +
+ ); + const liveRegion = screen.getByRole('status'); + const input = screen.getByRole('combobox'); + + await userEvent.click(input); + await waitFor(() => expect(liveRegion).toHaveTextContent('1 autocomplete option found.')); + + await userEvent.click(screen.getByRole('button', { name: 'outside' })); + expect(liveRegion).toBeEmptyDOMElement(); + + await userEvent.click(input); + expect(screen.getByRole('status')).toBe(liveRegion); + await waitFor(() => expect(liveRegion).toHaveTextContent('1 autocomplete option found.')); + }); + + it('announces usage instructions only during the first activation', async () => { + render( +
+
additional description
+ + + + item1 + + + +
+ ); + const input = screen.getByRole('combobox'); + const liveRegion = screen.getByRole('status'); + + expect(input).toHaveAttribute('aria-describedby', 'additional-description'); + + await userEvent.click(input); + const instructions = await screen.findByText('usage instructions'); + expect(instructions).toHaveAttribute('aria-live', 'polite'); + expect(instructions).not.toHaveAttribute('role'); + await waitFor(() => expect(liveRegion).toHaveTextContent('screen reader text here')); + await userEvent.click(screen.getByRole('button', { name: 'outside' })); + expect(input).toHaveAttribute('aria-describedby', 'additional-description'); + + await userEvent.click(input); + expect(input).toHaveAttribute('aria-describedby', 'additional-description'); + await waitFor(() => expect(liveRegion).toHaveTextContent('screen reader text here')); + expect(screen.queryByText('usage instructions')).not.toBeInTheDocument(); + }); + + it('previews focused options in the input without competing announcements', async () => { const dropdownProps: DropdownProps = { screenReaderText: 'screen reader text here' }; @@ -67,23 +128,40 @@ describe('Dropdown', () => { - + `autocomplete suggestion: ${value}`} + > item1 + item2 + item3 ); const inputNode = screen.getByRole('combobox'); - await userEvent.click(inputNode); - const itemNode = screen.getByText('item1'); + const liveRegion = screen.getByRole('status'); + await userEvent.type(inputNode, 'i'); + const itemNode = screen.getByRole('option', { name: 'autocomplete suggestion: item1' }); + const itemLabelNode = screen.getByText('autocomplete suggestion: item1'); + + expect(itemNode.tagName).toBe('DIV'); + expect(itemNode).toHaveAttribute('aria-labelledby', itemLabelNode.id); + expect(screen.getByText('item1')).toHaveAttribute('aria-hidden', 'true'); + expect(inputNode).toHaveAttribute('aria-expanded', 'true'); await userEvent.keyboard('{arrowdown}'); expect(itemNode.className).toContain('FocusedItem1'); expect(inputNode).toHaveValue('item1'); + expect(inputNode).toHaveAttribute('aria-activedescendant', itemNode.id); + expect(itemNode).toHaveAttribute('aria-selected', 'true'); + expect(liveRegion).toBeEmptyDOMElement(); await userEvent.keyboard('{arrowup}'); expect(itemNode.className).not.toContain('FocusedItem1'); - expect(inputNode).not.toHaveValue('item1'); + expect(itemNode).toHaveAttribute('aria-selected', 'false'); + expect(inputNode).toHaveValue('i'); }); it('closes the dropdown menu when tab key is pressed', async () => { @@ -172,7 +250,7 @@ describe('Dropdown', () => { expect(mockedOnSelectFn).toHaveBeenCalledWith('item1', 0, undefined); }); - it('selects when an option is focused on toggle', async () => { + it('does not select a focused option when the dropdown is closed', async () => { const mockedOnToggleFn = jest.fn(); const dropdownProps: DropdownProps = { screenReaderText: 'screen reader text here', @@ -223,7 +301,7 @@ describe('Dropdown', () => { ); const inputNode = screen.getByRole('combobox'); await userEvent.click(inputNode); - const itemNode = screen.getByText('item1'); + const itemNode = screen.getByRole('option'); expect(itemNode).toBeDefined(); expect(inputNode).toHaveValue(''); diff --git a/tests/components/FilterSearch.stories.tsx b/tests/components/FilterSearch.stories.tsx index c9449198..bd80437d 100644 --- a/tests/components/FilterSearch.stories.tsx +++ b/tests/components/FilterSearch.stories.tsx @@ -64,7 +64,7 @@ DropdownUnsectioned.parameters = { DropdownUnsectioned.play = async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.type(canvas.getByRole('combobox'), 'name'); - await canvas.findByText('first name 1'); + await canvas.findByRole('option', { name: 'first name 1' }); }; export const DropdownSectioned: StoryFn = Primary.bind({}); @@ -79,7 +79,7 @@ DropdownSectioned.parameters = { DropdownSectioned.play = async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.type(canvas.getByRole('combobox'), 'name'); - await canvas.findByText('first name 1'); + await canvas.findByRole('option', { name: 'first name 1' }); }; export const NoLabel: StoryFn = Primary.bind({}); diff --git a/tests/components/FilterSearch.test.tsx b/tests/components/FilterSearch.test.tsx index f1fce8fb..a01f97dc 100644 --- a/tests/components/FilterSearch.test.tsx +++ b/tests/components/FilterSearch.test.tsx @@ -1,5 +1,5 @@ import { FilterSearch, FilterSearchProps } from '../../src/components/FilterSearch'; -import { render, RenderResult, screen } from '@testing-library/react'; +import { render, RenderResult, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as searchOperations from '../../src/utils/search-operations'; import { SearchI18nextProvider } from '../../src/components/SearchI18nextProvider'; @@ -227,6 +227,49 @@ describe('search with section labels', () => { }); + it('uses the complete filter result as its accessible name when highlighting splits the value', async () => { + const highlightedResponse = { + ...unlabeledFilterSearchResponse, + sections: [{ + results: [{ + ...unlabeledFilterSearchResponse.sections[0].results[0], + matchedSubstrings: [{ offset: 1, length: 1 }] + }] + }] + }; + jest.spyOn(SearchHeadless.prototype, 'executeFilterSearch') + .mockResolvedValue(highlightedResponse); + renderFilterSearch(); + + await userEvent.type(screen.getByRole('combobox'), 'i'); + + expect(await screen.findByRole('option', { name: 'first name 1' })) + .toHaveAccessibleName('first name 1'); + expect(screen.getByText('f')).toBeInTheDocument(); + expect(screen.getByText('i')).toBeInTheDocument(); + expect(screen.getByText('rst name 1')).toBeInTheDocument(); + }); + + it('announces instructions when initially focused before filter results are requested', async () => { + const executeFilterSearch = jest.spyOn(SearchHeadless.prototype, 'executeFilterSearch') + .mockResolvedValue(labeledFilterSearchResponse); + renderFilterSearch(); + const instructions = 'When autocomplete results are available, ' + + 'use up and down arrows to review and enter to select.'; + + await userEvent.tab(); + + expect(screen.getByRole('combobox')).toHaveFocus(); + expect(await screen.findByText(instructions)).toBeInTheDocument(); + expect(executeFilterSearch).not.toHaveBeenCalled(); + + await userEvent.type(screen.getByRole('combobox'), 'n'); + + expect(await screen.findByRole('option', { name: 'first name 1' })) + .toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText(instructions)).toBeInTheDocument(); + }); + it('input value stays the same when a user selects a filter', async () => { const executeFilterSearch = jest .spyOn(SearchHeadless.prototype, 'executeFilterSearch') @@ -235,13 +278,32 @@ describe('search with section labels', () => { const searchBarElement = screen.getByRole('combobox'); await userEvent.type(searchBarElement, 'n'); - await screen.findByText('first name 1'); + await screen.findByRole('option', { name: 'first name 1' }); await userEvent.type(searchBarElement, '{arrowdown}'); expect(executeFilterSearch).toHaveBeenCalled(); expect(searchBarElement).toHaveValue('n'); }); + it('exposes the auto-selected first result before advancing to the next result', async () => { + jest.spyOn(SearchHeadless.prototype, 'executeFilterSearch') + .mockResolvedValue(labeledFilterSearchResponse); + renderFilterSearch(); + const searchBarElement = screen.getByRole('combobox'); + + await userEvent.type(searchBarElement, 'n'); + const firstOption = await screen.findByRole('option', { name: 'first name 1' }); + const secondOption = screen.getByRole('option', { name: 'first name 2' }); + expect(firstOption).toHaveAttribute('aria-selected', 'true'); + expect(secondOption).toHaveAttribute('aria-selected', 'false'); + expect(searchBarElement).toHaveAttribute('aria-activedescendant', firstOption.id); + + await userEvent.keyboard('{arrowdown}'); + expect(searchBarElement).toHaveAttribute('aria-activedescendant', secondOption.id); + expect(firstOption).toHaveAttribute('aria-selected', 'false'); + expect(secondOption).toHaveAttribute('aria-selected', 'true'); + }); + it('remove old filter value when a new one is entered', async () => { renderFilterSearch(); const executeFilterSearch = jest @@ -253,7 +315,7 @@ describe('search with section labels', () => { await userEvent.type(searchBarElement, 'n'); await waitForDebounce(); expect(executeFilterSearch).toHaveBeenCalled(); - await screen.findByText('first name 1'); + await screen.findByRole('option', { name: 'first name 1' }); await userEvent.type(searchBarElement, '{enter}'); expect(setFilterOption).toHaveBeenCalledWith({ filter: { @@ -269,7 +331,7 @@ describe('search with section labels', () => { await userEvent.clear(searchBarElement); await userEvent.type(searchBarElement, 'n'); await waitForDebounce(); - await screen.findByText('first name 2'); + await screen.findByRole('option', { name: 'first name 2' }); await userEvent.type(searchBarElement, '{arrowdown}'); await userEvent.type(searchBarElement, '{enter}'); @@ -338,7 +400,7 @@ describe('search with section labels', () => { await userEvent.type(searchBarElement, 'n'); await waitForDebounce(); expect(executeFilterSearch).toHaveBeenCalled(); - await screen.findByText('first name 1'); + await screen.findByRole('option', { name: 'first name 1' }); await userEvent.type(searchBarElement, '{enter}'); expect(setFilterOption).toHaveBeenCalledWith({ filter: { @@ -377,7 +439,7 @@ describe('search with section labels', () => { await userEvent.type(searchBarElement, 'n'); await waitForDebounce(); expect(executeFilterSearch).toHaveBeenCalled(); - await screen.findByText('first name 1'); + await screen.findByRole('option', { name: 'first name 1' }); await userEvent.type(searchBarElement, '{enter}'); expect(setFilterOption).toHaveBeenCalledWith({ filter: { @@ -423,7 +485,7 @@ describe('search with section labels', () => { const searchBarElement = screen.getByRole('combobox'); await userEvent.type(searchBarElement, 'n'); - await screen.findByText('first name 1'); + await screen.findByRole('option', { name: 'first name 1' }); await userEvent.type(searchBarElement, '{enter}'); expect(executeFilterSearch).toHaveBeenCalled(); @@ -497,7 +559,7 @@ describe('search with section labels', () => { await userEvent.type(searchBarElement, 'f'); await waitForDebounce(); expect(executeFilterSearch).toHaveBeenCalled(); - await screen.findByText('first name 1'); + await screen.findByRole('option', { name: 'first name 1' }); await userEvent.type(searchBarElement, '{enter}'); expect(setFilterOption).toHaveBeenCalledWith({ filter: { @@ -559,7 +621,7 @@ describe('search with section labels', () => { await userEvent.type(searchBarElement, 'n'); await waitForDebounce(); expect(executeFilterSearch).toHaveBeenCalled(); - await screen.findByText('first name 1'); + await screen.findByRole('option', { name: 'first name 1' }); const expectedSetFilterOptionParam = { filter: { @@ -618,7 +680,7 @@ describe('search with section labels', () => { await userEvent.type(searchBarElement, 'n'); await waitForDebounce(); expect(executeFilterSearch).toHaveBeenCalled(); - const autocompleteSuggestion = await screen.findByText('first name 1'); + const autocompleteSuggestion = await screen.findByRole('option', { name: 'first name 1' }); const expectedSetFilterOptionParam = { @@ -664,7 +726,7 @@ describe('search with section labels', () => { const searchBarElement = screen.getByRole('combobox'); await userEvent.type(searchBarElement, 'n'); - await screen.findByText('first name 1'); + await screen.findByRole('option', { name: 'first name 1' }); await userEvent.type(searchBarElement, '{enter}'); expect(executeFilterSearch).toHaveBeenCalled(); @@ -691,7 +753,7 @@ describe('search with section labels', () => { const searchBarElement = screen.getByRole('combobox'); await userEvent.type(searchBarElement, 'n'); - await screen.findByText('first name 1'); + await screen.findByRole('option', { name: 'first name 1' }); await userEvent.type(searchBarElement, '{enter}'); expect(executeFilterSearch).toHaveBeenCalled(); @@ -726,8 +788,9 @@ describe('search without section labels', () => { await waitForDebounce(); expect(executeFilterSearch).toHaveBeenCalled(); - const autocompleteSuggestion = screen.getByText('first name 1'); + const autocompleteSuggestion = screen.getByRole('option', { name: 'first name 1' }); expect(autocompleteSuggestion).toBeDefined(); + expect(screen.queryByRole('group')).not.toBeInTheDocument(); }); it('pressing enter without navigating selects first filter in input', async () => { @@ -789,6 +852,9 @@ describe('search without section labels', () => { }); describe('screen reader', () => { + const expectedInstructions = 'When autocomplete results are available, ' + + 'use up and down arrows to review and enter to select.'; + it('renders ScreenReader messages with section labels', async () => { const executeFilterSearch = jest .spyOn(SearchHeadless.prototype, 'executeFilterSearch') @@ -798,19 +864,23 @@ describe('screen reader', () => { const searchBarElement = screen.getByRole('combobox'); await userEvent.type(searchBarElement, 'n'); + expect(screen.getByRole('status')).toBeEmptyDOMElement(); await waitForDebounce(); expect(executeFilterSearch).toHaveBeenCalled(); const expectedScreenReaderMessage = '2 First name autocomplete options found. 1 Last name autocomplete option found.'; - const screenReaderMessage = screen.getByText(expectedScreenReaderMessage); - expect(screenReaderMessage).toBeDefined(); + const screenReaderMessage = screen.getByRole('status'); + expect(await screen.findByText(expectedInstructions)).toBeInTheDocument(); + await waitFor(() => expect(screenReaderMessage).toHaveTextContent(expectedScreenReaderMessage)); rerenderWithLocale('fr'); const expectedLocalizedScreenReaderMessage = '2 options d\'autocomplétion First name trouvées. 1 option d\'autocomplétion Last name trouvée.'; - const rerenderedScreenReaderMessage = screen.getByText(expectedLocalizedScreenReaderMessage); - expect(rerenderedScreenReaderMessage).toBeDefined(); + await waitFor(() => expect(screenReaderMessage).toHaveTextContent( + expectedLocalizedScreenReaderMessage + )); + expect(screen.getByText(expectedInstructions)).toBeInTheDocument(); }); it('renders ScreenReader messages without section labels', async () => { @@ -827,9 +897,9 @@ describe('screen reader', () => { expect(executeFilterSearch).toHaveBeenCalled(); const expectedScreenReaderMessage = '3 autocomplete options found.'; - const screenReaderMessage = screen.getByText(expectedScreenReaderMessage); + const screenReaderMessage = screen.getByRole('status'); - expect(screenReaderMessage).toBeDefined(); + await waitFor(() => expect(screenReaderMessage).toHaveTextContent(expectedScreenReaderMessage)); }); it('renders 0 results ScreenReader message when there are no results', async () => { @@ -840,14 +910,15 @@ describe('screen reader', () => { const searchBarElement = screen.getByRole('combobox'); await userEvent.type(searchBarElement, 'n'); + expect(screen.getByRole('status')).toBeEmptyDOMElement(); await waitForDebounce(); - await pause(50); // wait for the screen reader message to be updated expect(executeFilterSearch).toHaveBeenCalled(); const expectedScreenReaderMessage = '0 autocomplete options found.'; - const screenReaderMessage = screen.getByText(expectedScreenReaderMessage); + const screenReaderMessage = screen.getByRole('status'); - expect(screenReaderMessage).toBeDefined(); + expect(await screen.findByText(expectedInstructions)).toBeInTheDocument(); + await waitFor(() => expect(screenReaderMessage).toHaveTextContent(expectedScreenReaderMessage)); }); }); diff --git a/tests/components/MapboxMap.stories.tsx b/tests/components/MapboxMap.stories.tsx index ead91251..b78f4042 100644 --- a/tests/components/MapboxMap.stories.tsx +++ b/tests/components/MapboxMap.stories.tsx @@ -1,7 +1,6 @@ import { Meta, StoryFn } from '@storybook/react'; -import { within } from '@storybook/testing-library'; +import { userEvent, within } from '@storybook/testing-library'; import { expect, fn } from '@storybook/test'; -import { fireEvent } from '@testing-library/react'; import { SearchHeadlessContext } from '@yext/search-headless-react'; import { generateMockedHeadless } from '../__fixtures__/search-headless'; @@ -62,7 +61,7 @@ CustomPin.play = async ({ canvasElement }) => { const mapPin = await canvas.findByLabelText('Show pin details', undefined, { timeout: 30000 }); - fireEvent.click(mapPin); + await userEvent.click(mapPin); await canvas.findByText('title1'); }; @@ -77,7 +76,7 @@ CustomRenderPin.play = async ({ canvasElement, args }) => { const mapPin = await canvas.findByLabelText('Show pin details', undefined, { timeout: 30000 }); - fireEvent.click(mapPin); + await userEvent.click(mapPin); await expect(args.onPinClick).toHaveBeenCalledWith(expect.objectContaining({ name: 'title1', })); diff --git a/tests/components/ScreenReader.test.tsx b/tests/components/ScreenReader.test.tsx new file mode 100644 index 00000000..480aca2c --- /dev/null +++ b/tests/components/ScreenReader.test.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { ScreenReader } from '../../src/components/ScreenReader'; + +describe('ScreenReader', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('updates a stable live region after it has mounted', () => { + const { rerender } = render( + + ); + const liveRegion = screen.getByRole('status'); + + expect(liveRegion).toBeEmptyDOMElement(); + act(() => jest.advanceTimersByTime(0)); + expect(liveRegion).toHaveTextContent('first announcement'); + + rerender( + + ); + expect(screen.getByRole('status')).toBe(liveRegion); + expect(liveRegion).toBeEmptyDOMElement(); + + act(() => jest.advanceTimersByTime(0)); + expect(liveRegion).toHaveTextContent('second announcement'); + }); + + it('only renders the newest pending announcement', () => { + const { rerender } = render( + + ); + + act(() => jest.advanceTimersByTime(400)); + rerender( + + ); + act(() => jest.advanceTimersByTime(800)); + + expect(screen.getByRole('status')).toHaveTextContent('latest complete announcement'); + expect(screen.queryByText('stale announcement')).not.toBeInTheDocument(); + }); +}); diff --git a/tests/components/SearchBar.test.tsx b/tests/components/SearchBar.test.tsx index ff7bd205..25f63b9d 100644 --- a/tests/components/SearchBar.test.tsx +++ b/tests/components/SearchBar.test.tsx @@ -5,8 +5,14 @@ import { SearchHeadlessContext, State, SearchTypeEnum } from '@yext/search-headless-react'; -import { render, RenderResult, screen } from '@testing-library/react'; -import { SearchBar, onSearchFunc, VerticalLink, SearchI18nextProvider, SearchAnalyticsEventService } from '../../src'; +import { render, RenderResult, screen, waitFor } from '@testing-library/react'; +import { + SearchBar, + onSearchFunc, + VerticalLink, + SearchI18nextProvider, + SearchAnalyticsEventService +} from '../../src'; import userEvent from '@testing-library/user-event'; import { generateMockedHeadless } from '../__fixtures__/search-headless'; import { RecursivePartial } from '../__utils__/mocks'; @@ -219,6 +225,37 @@ describe('SearchBar', () => { })); }); + it('previews a highlighted suggestion and restores the typed query', async () => { + jest.spyOn(SearchCore.prototype, 'universalAutocomplete').mockResolvedValue({ + results: [{ + value: 'got any grapes?', + inputIntents: [], + matchedSubstrings: [{ offset: 4, length: 1 }] + }], + inputIntents: [], + uuid: '' + }); + + renderSearchBar(mockedState); + const input = screen.getByRole('combobox'); + await userEvent.type(input, 'a'); + const option = await screen.findByRole('option', { + name: 'autocomplete suggestion: got any grapes?' + }); + + await userEvent.keyboard('{arrowdown}'); + + expect(input).toHaveValue('got any grapes?'); + expect(input).toHaveAttribute('aria-activedescendant', option.id); + + await userEvent.keyboard('{arrowup}'); + expect(input).toHaveValue('a'); + + await userEvent.keyboard('{arrowdown}'); + await userEvent.keyboard('{enter}'); + expect(input).toHaveValue('got any grapes?'); + }); + it('uses universal autocomplete limit when universalAutocompleteLimit is provided', async () => { const mockedUniversalAutocomplete = jest .spyOn(SearchCore.prototype, 'universalAutocomplete') @@ -612,11 +649,37 @@ describe('SearchBar', () => { }); describe('Screen reader text', () => { - it('search bar instruction text for screen reader is present in DOM', () => { + it('announces search bar usage instructions only on initial focus', async () => { + jest.spyOn(SearchCore.prototype, 'universalAutocomplete').mockResolvedValue({ + results: [{ value: 'query suggestion', inputIntents: [] }], + inputIntents: [], + uuid: '' + }); renderSearchBar(mockedState); - expect(screen.getByText( + const input = screen.getByRole('combobox'); + const liveRegion = screen.getByRole('status'); + + expect(input).not.toHaveAttribute('aria-describedby'); + + await userEvent.click(input); + expect(await screen.findByText( 'When autocomplete results are available, use up and down arrows to review and enter to select.' )).toBeInTheDocument(); + await waitFor(() => expect(liveRegion).toHaveTextContent( + '1 autocomplete suggestion found.' + ), { timeout: 2000 }); + + await userEvent.tab(); + expect(input).not.toHaveAttribute('aria-describedby'); + + await userEvent.click(input); + await waitFor( + () => expect(liveRegion).toHaveTextContent('1 autocomplete suggestion found.'), + { timeout: 2000 } + ); + expect(screen.queryByText( + 'When autocomplete results are available, use up and down arrows to review and enter to select.' + )).not.toBeInTheDocument(); }); it('description text of number of available autocomplete options is present in DOM', async () => { @@ -632,9 +695,9 @@ describe('SearchBar', () => { .mockResolvedValue(mockedAutocompleteResponse); renderSearchBar(mockedState); await userEvent.click(screen.getByRole('combobox')); - expect(await screen.findByText( + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent( '2 autocomplete suggestions found.' - )).toBeInTheDocument(); + ), { timeout: 2000 }); }); it('description text of number of available recent search options is present in DOM', async () => { @@ -642,9 +705,9 @@ describe('SearchBar', () => { await userEvent.type(screen.getByRole('combobox'), 'yext'); await userEvent.keyboard('{enter}'); await userEvent.click(screen.getByRole('combobox')); - expect(await screen.findByText( + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent( '1 recent search found.' - )).toBeInTheDocument(); + )); }); }); });