Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
06df239
screen reader fixes
k-gerner Jul 24, 2026
6d70f2d
fix wcag test fails
k-gerner Jul 24, 2026
7e6f200
Update snapshots
github-actions[bot] Jul 24, 2026
925410e
Update snapshots
github-actions[bot] Jul 24, 2026
72369bc
Update snapshots
github-actions[bot] Jul 24, 2026
dce7176
Update snapshots
github-actions[bot] Jul 24, 2026
c534474
Update snapshots
github-actions[bot] Jul 24, 2026
1c549c7
Update snapshots
github-actions[bot] Jul 24, 2026
79ddc76
Update snapshots
github-actions[bot] Jul 24, 2026
3e7476e
Update snapshots
github-actions[bot] Jul 24, 2026
8006b48
Update snapshots
github-actions[bot] Jul 24, 2026
a49434c
show suggestion text in search bar when highlighting suggestion with …
k-gerner Aug 17, 2026
bd2dc48
rebase
k-gerner Aug 18, 2026
d33a794
Update snapshots
github-actions[bot] Aug 18, 2026
d6dbdcf
Update snapshots
github-actions[bot] Aug 18, 2026
848ee1f
Update snapshots
github-actions[bot] Aug 18, 2026
684f87f
Update snapshots
github-actions[bot] Aug 18, 2026
629a9db
Update snapshots
github-actions[bot] Aug 18, 2026
00f0378
Update snapshots
github-actions[bot] Aug 18, 2026
f0a3ffd
Update snapshots
github-actions[bot] Aug 18, 2026
592af6e
Update snapshots
github-actions[bot] Aug 18, 2026
7fa3581
Update snapshots
github-actions[bot] Aug 18, 2026
e17bef8
Update snapshots
github-actions[bot] Aug 18, 2026
206ad1b
Update snapshots
github-actions[bot] Aug 18, 2026
5892926
Update snapshots
github-actions[bot] Aug 18, 2026
7bd2bcc
Update snapshots
github-actions[bot] Aug 18, 2026
0a8ae21
Update snapshots
github-actions[bot] Aug 18, 2026
99469dd
Update snapshots
github-actions[bot] Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .storybook/snapshots/__snapshots__/mapboxmap--custom-pin.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion locales/en-GB/search-ui-react.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion locales/en/search-ui-react.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
43 changes: 34 additions & 9 deletions src/components/Dropdown/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -68,8 +69,11 @@ export function Dropdown(props: PropsWithChildren<DropdownProps>): React.JSX.Ele
const containerRef = useRef<HTMLDivElement>(null);
const screenReaderUUID = useId('dropdown');
const dropdownListUUID = useId('dropdown-list');
const [screenReaderKey, setScreenReaderKey] = useState<number>(0);
const [hasTyped, setHasTyped] = useState<boolean>(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]);
Expand All @@ -81,7 +85,6 @@ export function Dropdown(props: PropsWithChildren<DropdownProps>): React.JSX.Ele
items,
lastTypedOrSubmittedValue,
setValue,
setScreenReaderKey,
alwaysSelectOption
);
const { focusedIndex, focusedItemData, updateFocusedItem } = focusContext;
Expand All @@ -93,12 +96,26 @@ export function Dropdown(props: PropsWithChildren<DropdownProps>): 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);
Expand All @@ -122,6 +139,9 @@ export function Dropdown(props: PropsWithChildren<DropdownProps>): React.JSX.Ele

if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
setIsNavigatingOptions(true);
} else {
setIsNavigatingOptions(false);
}

if (e.key === 'ArrowDown') {
Expand All @@ -144,6 +164,9 @@ export function Dropdown(props: PropsWithChildren<DropdownProps>): React.JSX.Ele
}
}

const resultAnnouncement = isActive && !isNavigatingOptions && (hasTyped || items.length || value)
? screenReaderText
: '';
return (
<div ref={containerRef} className={isActive ? activeClassName : className} onKeyDown={handleKeyDown}>
<DropdownContext.Provider value={dropdownContext}>
Expand All @@ -154,11 +177,12 @@ export function Dropdown(props: PropsWithChildren<DropdownProps>): React.JSX.Ele
</InputContext.Provider>
</DropdownContext.Provider>

<div className='sr-only' aria-live='polite' aria-atomic='true'>
{instructionsAnnouncement}
</div>
<ScreenReader
announcementKey={screenReaderKey}
announcementText={isActive && (hasTyped || items.length || value) ? screenReaderText : ''}
instructionsId={screenReaderUUID}
instructions={screenReaderInstructions ?? t('dropDownScreenReaderInstructions')}
announcementText={resultAnnouncement}
announcementDelayMs={resultAnnouncementDelayMs}
/>
</div>
);
Expand All @@ -179,7 +203,6 @@ function useFocusContextInstance(
items: DropdownItemData[],
lastTypedOrSubmittedValue: string,
setValue: (newValue: string) => void,
setScreenReaderKey: React.Dispatch<React.SetStateAction<number>>,
alwaysSelectOption: boolean
): FocusContextType {
const [focusedIndex, setFocusedIndex] = useState(-1);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -243,7 +264,9 @@ function useDropdownContextInstance(
focusedItemData: Record<string, unknown> | undefined,
screenReaderUUID: string | undefined,
dropdownListUUID: string | undefined,
hasPopup: boolean,
setHasTyped: (hasTyped: boolean) => void,
setIsNavigatingOptions: (isNavigating: boolean) => void,
onToggle?: (
isActive: boolean,
prevValue: string,
Expand All @@ -255,6 +278,7 @@ function useDropdownContextInstance(
): DropdownContextType {
const [isActive, _toggleDropdown] = useState(false);
const toggleDropdown = (willBeOpen: boolean) => {
setIsNavigatingOptions(false);
if (!willBeOpen) {
setHasTyped(false);
}
Expand All @@ -263,6 +287,7 @@ function useDropdownContextInstance(
};
return {
isActive,
isExpanded: isActive && hasPopup,
toggleDropdown,
onSelect,
screenReaderUUID,
Expand Down
5 changes: 4 additions & 1 deletion src/components/Dropdown/DropdownContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,4 +22,4 @@ export function useDropdownContext(): DropdownContextType {
throw new Error('Tried to use DropdownContext when none exists.');
}
return dropdownContextInstance;
}
}
34 changes: 18 additions & 16 deletions src/components/Dropdown/DropdownInput.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -33,46 +33,48 @@ export function DropdownInput(props: {
} = props;

const inputRef = useRef<HTMLInputElement>(null);
const { toggleDropdown, onSelect, screenReaderUUID, dropdownListUUID, isActive } = useDropdownContext();
const {
toggleDropdown,
onSelect,
screenReaderUUID,
dropdownListUUID,
isExpanded
} = useDropdownContext();
const { value = '', setLastTypedOrSubmittedValue } = useInputContext();
const {
focusedIndex = -1,
focusedItemData,
focusedValue,
updateFocusedItem
} = useFocusContext();
const [isTyping, setIsTyping] = useState<boolean>(true);
const describedBy = [screenReaderUUID, ariaDescribedBy].filter(Boolean).join(' ') || undefined;
const resolvedAriaLabel = ariaLabelledBy ? undefined : ariaLabel;

const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setIsTyping(true);
toggleDropdown(true);
onChange?.(e.target.value);
updateFocusedItem(-1, e.target.value);
setLastTypedOrSubmittedValue(e.target.value);
}, [onChange, setLastTypedOrSubmittedValue, toggleDropdown, updateFocusedItem]);

const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
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,
focusedValue,
focusedItemData,
onSelect,
onSubmit,
setLastTypedOrSubmittedValue,
submitCriteria,
toggleDropdown,
updateFocusedItem,
Expand All @@ -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"
/>
);
}
}
23 changes: 13 additions & 10 deletions src/components/Dropdown/DropdownItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 (
<button
id={generateDropdownId(screenReaderUUID, index)}
type="button"
tabIndex={-1}
<div
id={optionId}
className={combinedClassName}
onClick={handleClick}
aria-label={typeof ariaLabel === 'function' ? ariaLabel(value) : ariaLabel}
aria-labelledby={ariaLabelId}
role="option"
aria-selected={isFocused}
>
{children}
</button>
{resolvedAriaLabel && <span id={ariaLabelId} className='sr-only'>{resolvedAriaLabel}</span>}
<div className='contents' aria-hidden={resolvedAriaLabel ? 'true' : undefined}>
{children}
</div>
</div>
);
}
5 changes: 3 additions & 2 deletions src/components/FilterSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ export function FilterSearch({
<div
className='pb-2'
key={sectionIndex}
role='group'
role={section.label ? 'group' : undefined}
aria-labelledby={sectionLabelId}
>
{section.label &&
Expand All @@ -348,6 +348,7 @@ export function FilterSearch({
focusedClassName={cssClasses.focusedOption}
value={result.value}
itemData={itemDataMatrix[sectionIndex][index]}
ariaLabel={result.value}
>
{renderAutocompleteResult(result, cssClasses)}
</DropdownItem>
Expand Down Expand Up @@ -399,7 +400,7 @@ export function FilterSearch({
</label>
)}
<Dropdown
screenReaderText={getScreenReaderText(sections, t)}
screenReaderText={filterSearchResponse ? getScreenReaderText(sections, t) : ''}
onSelect={handleSelectDropdown}
alwaysSelectOption={true}
parentQuery={filterQuery}
Expand Down
51 changes: 26 additions & 25 deletions src/components/ScreenReader.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,36 @@
import React from 'react';
import React, { useEffect, useState } from 'react';

interface Props {
instructionsId?: string,
instructions: string,
announcementKey: number,
announcementText: string
announcementText: string,
announcementDelayMs?: number
}

export function ScreenReader({
instructionsId,
instructions,
announcementKey,
announcementText,
}: Props): React.JSX.Element | null {
announcementDelayMs = 0
}: Props): React.JSX.Element {
const [renderedAnnouncement, setRenderedAnnouncement] = useState('');

useEffect(() => {
setRenderedAnnouncement('');
if (!announcementText) {
return;
}

const timeoutId = setTimeout(() => {
setRenderedAnnouncement(announcementText);
}, announcementDelayMs);
return () => clearTimeout(timeoutId);
}, [announcementDelayMs, announcementText]);

return (
<>
<div
id={instructionsId}
className='sr-only'
>
{instructions}
</div>
<div
className='sr-only'
key={announcementKey}
aria-live='polite'
aria-atomic='true'
>
{announcementText}
</div>
</>
<div
className='sr-only'
role='status'
aria-live='polite'
aria-atomic='true'
>
{renderedAnnouncement}
</div>
);
}
Loading
Loading