import { CrossCircle } from '@transferwise/icons'; import { ListboxOptions } from '@headlessui/react'; import { clsx } from 'clsx'; import { useEffect, useId, useMemo, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; import { Virtualizer, type VirtualizerHandle } from 'virtua'; import { SearchInput } from '../../SearchInput'; import { SelectInputItemsCountContext, SelectInputItemPositionContext, } from '../SelectInput.contexts'; import { dedupeSelectInputItems, filterSelectInputItems, MAX_ITEMS_WITHOUT_VIRTUALIZATION, searchableString, selectInputOptionItemIncludesNeedle, sortSelectInputItems, } from '../SelectInput.utils'; import { SelectInputOptionItem, SelectInputProps, SelectInputItem } from '../SelectInput.types'; import messages from '../SelectInput.messages'; import { SelectInputItemView } from '../ItemView'; import { SelectInputOptionsContainer } from './OptionsContainer'; /** * Props for the SelectInputOptions component. */ export interface SelectInputOptionsProps extends Pick< SelectInputProps, | 'items' | 'renderValue' | 'renderFooter' | 'filterable' | 'filterPlaceholder' | 'id' | 'parentId' | 'compareValues' | 'sortFilteredOptions' > { searchInputRef: React.MutableRefObject; listboxRef: React.MutableRefObject; filterQuery: string; onFilterChange: (query: string) => void; listBoxLabel?: string; listBoxLabelledBy?: string; autocomplete?: string; name?: string; onAutocompleteSelect?: (value: T) => void; } /** * The main options container component for SelectInput. * Manages filtering, virtualisation, and rendering of options. */ export function SelectInputOptions({ id, parentId, items, compareValues: compareValuesProp, renderValue = String, renderFooter, filterable = false, filterPlaceholder, sortFilteredOptions, searchInputRef, listboxRef, filterQuery, onFilterChange, listBoxLabel, listBoxLabelledBy, autocomplete, name, onAutocompleteSelect, }: SelectInputOptionsProps) { const intl = useIntl(); const virtualiserHandlerRef = useRef(null); const controllerRef = filterable ? searchInputRef : listboxRef; const [initialRender, setInitialRender] = useState(true); const needle = useMemo(() => { if (filterable) { return filterQuery ? searchableString(filterQuery) : null; } return undefined; }, [filterQuery, filterable]); useEffect(() => { if (needle) { // Ensure having an active option while filtering. // Without `requestAnimationFrame` upon which React depends for scheduling // updates, the active status would only show for a split second and then // disappear inadvertently. requestAnimationFrame(() => { if ( controllerRef.current != null && !controllerRef.current.hasAttribute('aria-activedescendant') ) { // Activate first option via synthetic key press controllerRef.current.dispatchEvent( new KeyboardEvent('keydown', { key: 'Home', bubbles: true }), ); } }); } }, [controllerRef, needle]); const compareValues = useMemo(() => { if (!compareValuesProp) { return undefined; } if (typeof compareValuesProp === 'function') { return (a: NonNullable, b: NonNullable) => compareValuesProp(a, b); } const key = compareValuesProp; return (a: NonNullable, b: NonNullable) => { if (typeof a === 'object' && a != null && typeof b === 'object' && b != null) { return (a as Record)[key] === (b as Record)[key]; } return a === b; }; }, [compareValuesProp]); const filteredItems: readonly SelectInputItem | undefined>[] = useMemo(() => { if (needle == null) { return items; } const dedupedItems = dedupeSelectInputItems(items, compareValues); if (sortFilteredOptions) { // When sorting, filter out non-matching items completely to avoid ghost items const filtered = dedupedItems.map((item) => { if (item.type === 'option') { return selectInputOptionItemIncludesNeedle(item, needle) ? item : { ...item, value: undefined }; } if (item.type === 'group') { return { ...item, options: item.options.map((option) => selectInputOptionItemIncludesNeedle(option, needle) ? option : { ...option, value: undefined }, ), }; } return item; }); return sortSelectInputItems(filtered, sortFilteredOptions, filterQuery); } return filterSelectInputItems(dedupedItems, (item) => selectInputOptionItemIncludesNeedle(item, needle), ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [needle, items, compareValues]); const resultsEmpty = needle != null && filteredItems.length === 0; const virtualized = filteredItems.length > MAX_ITEMS_WITHOUT_VIRTUALIZATION; // Items shown once shall be kept mounted until the needle changes, otherwise // the scroll position may jump around inadvertently. Pattern adopted from: // https://inokawa.github.io/virtua/?path=/story/advanced-keep-offscreen-items--append-only const [mountedIndexes, setMountedIndexes] = useState([]); const prevNeedleRef = useRef(needle); useEffect(() => { const needleChanged = prevNeedleRef.current !== needle; prevNeedleRef.current = needle; if (needleChanged) { // Reset mounted indexes when search changes to avoid stale scroll positions setMountedIndexes([]); return; } // Ensure the 'End' key works as intended by keeping the last item mounted. // Skipped on needle change to prevent auto-scrolling on search. if (filteredItems.length > 0) { setMountedIndexes((prevMountedIndexes) => { // Create a new array with existing indexes plus the last item index return [...new Set([...prevMountedIndexes, filteredItems.length - 1])]; // Sorting is redundant by nature here }); } }, [needle, filteredItems.length]); const listboxContainerRef = useRef(null); useEffect(() => { if (listboxContainerRef.current != null) { listboxContainerRef.current.style.setProperty( '--initial-height', `${listboxContainerRef.current.offsetHeight}px`, ); } }, []); useEffect(() => { setInitialRender(false); }, []); const showStatus = resultsEmpty; const statusId = useId(); const listboxId = useId(); const getItemNode = (index: number) => { const item = filteredItems[index]; return ( ); }; const findMatchingItem = (autocompleteValue: string): T | null => { const flatOptions = items .flatMap((item) => item.type === 'group' ? item.options : item.type === 'option' ? [item] : [], ) .filter( (item): item is SelectInputOptionItem> => item.type === 'option' && item.value != null, ); const exactMatch = flatOptions.find( (option) => String(option.value) === autocompleteValue || option.filterMatchers?.some((matcher) => matcher === autocompleteValue), ); if (exactMatch) { return exactMatch.value; } const fuzzyMatch = flatOptions.find((option) => option.filterMatchers?.some((matcher) => matcher.toLowerCase().includes(autocompleteValue.toLowerCase()), ), ); return fuzzyMatch ? fuzzyMatch.value : null; }; return ( { if (controllerRef.current != null) { if (!initialRender && value != null) { controllerRef.current.setAttribute('aria-activedescendant', value); } else { controllerRef.current.removeAttribute('aria-activedescendant'); } } }} > {filterable ? (
) => { // Prevent interfering with the matcher of Headless UI // https://mathiasbynens.be/notes/javascript-unicode#regex if (/^.$/u.test(event.key)) { event.stopPropagation(); } }} onChange={(event) => { // Free up resources and ensure not to go out of bounds when the // resulting item count is less than before const inputValue = event.currentTarget.value; // Free up resources and ensure not to go out of bounds setMountedIndexes([]); onFilterChange(inputValue); }} onInput={(event) => { const inputValue = event.currentTarget.value; const inputElement = event.currentTarget; if (autocomplete && onAutocompleteSelect && inputValue) { setTimeout(() => { if (inputElement.value === inputValue && inputValue.length > 2) { const matchedValue = findMatchingItem(inputValue); if (matchedValue !== null) { onAutocompleteSelect(matchedValue); } } }, 50); } }} />
) : null}
item.type === 'group') && 'np-select-input-listbox-container--has-group', )} data-wds-parent={parentId ?? undefined} > {resultsEmpty ? (
{intl.formatMessage(messages.noResultsFound)}
) : null}
{!virtualized ? ( filteredItems.map((_, index) => getItemNode(index)) ) : ( { if (!virtualiserHandlerRef.current) return; const startIndex = virtualiserHandlerRef.current.findItemIndex( virtualiserHandlerRef.current.scrollOffset, ); const endIndex = virtualiserHandlerRef.current.findItemIndex( virtualiserHandlerRef.current.scrollOffset + virtualiserHandlerRef.current.viewportSize, ); setMountedIndexes((prevMountedIndexes) => { // Create an array of all indexes that should be visible const visibleIndexes = []; for (let index = startIndex; index <= endIndex; index += 1) { // eslint-disable-next-line functional/immutable-data visibleIndexes.push(index); } // Combine with previous indexes and sort return [...new Set([...prevMountedIndexes, ...visibleIndexes])].sort( (a, b) => a - b, ); }); }} > {(item, index) => ( // The position of each item can't be inferred by browsers when // virtualizing, as some of the items may not be in the DOM {getItemNode(index)} )} )}
{renderFooter != null ? ( ) : null}
); }