import { useTheme } from '@wise/components-theming'; import { clsx } from 'clsx'; import { useState, useEffect, useRef, useMemo, useId } from 'react'; import { useIntl } from 'react-intl'; import Button, { ButtonProps } from '../button'; import Chevron from '../chevron'; import { Position, Size } from '../common'; import BottomSheet from '../common/bottomSheet'; import { stopPropagation } from '../common/domHelpers'; import { useLayout } from '../common/hooks'; import Panel from '../common/panel'; import Drawer from '../drawer'; import { useInputAttributes } from '../inputs/contexts'; import messages from './Select.messages'; import Option from './option'; import SearchBox from './searchBox'; const DEFAULT_SEARCH_VALUE = ''; const DEFAULT_OPTIONS_PAGE_SIZE = 1000; const includesString = (string1: string, string2: string) => string1.toLowerCase().includes(string2.toLowerCase()); export interface SelectOptionItem { value: any; label?: React.ReactNode; icon?: React.ReactNode; currency?: string; note?: React.ReactNode; secondary?: React.ReactNode; } export interface SelectItem extends Partial { header?: React.ReactNode; separator?: boolean; disabled?: boolean; searchStrings?: string[]; } export interface SelectItemWithPlaceholder extends SelectItem { placeholder?: string; } function defaultFilterFunction(option: SelectItemWithPlaceholder, searchValue: string) { if (isPlaceholderOption(option)) { return true; } const { label, note, secondary, currency, searchStrings } = option; return ( (typeof label === 'string' && includesString(label, searchValue)) || (typeof note === 'string' && includesString(note, searchValue)) || (typeof secondary === 'string' && includesString(secondary, searchValue)) || (!!currency && includesString(currency, searchValue)) || (!!searchStrings && searchStrings.some((string) => includesString(string, searchValue))) ); } function isActionableOption(option: SelectItem) { return !option.header && !option.separator && !option.disabled; } function isHeaderOption(option: SelectItem | null) { return option != null && 'header' in option; } function isSeparatorOption(option: SelectItem | null) { return option != null && 'separator' in option; } function clamp(from: number, to: number, value: number) { return Math.max(Math.min(to, value), from); } /** * No option or placeholder option is selected */ const DEFAULT_SELECTED_OPTION = null; function isPlaceholderOption(option: SelectItemWithPlaceholder | null) { return option === DEFAULT_SELECTED_OPTION || 'placeholder' in option; } function isSearchableOption(option: SelectItemWithPlaceholder | null) { return !isHeaderOption(option) && !isSeparatorOption(option) && !isPlaceholderOption(option); } const getUniqueIdForOption = (parentId: string | undefined, option: SelectItem | null) => { if (option == null) { return undefined; } // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const uniqueOptionId = option.value || (typeof option.label === 'string' ? option.label.replace(/\s/g, '') : ''); return `option-${parentId ?? ''}-${uniqueOptionId}`; }; export interface SelectProps { placeholder?: string; id?: string; required?: boolean; disabled?: boolean; inverse?: boolean; dropdownRight?: `${Size.EXTRA_SMALL | Size.SMALL | Size.MEDIUM | Size.LARGE | Size.EXTRA_LARGE}`; dropdownWidth?: `${Size.SMALL | Size.MEDIUM | Size.LARGE}`; /** @default 'md' */ size?: `${Size.SMALL | Size.MEDIUM | Size.LARGE}`; /** @default true */ block?: boolean; selected?: SelectOptionItem; /** * Search toggle * if `true` default search functionality being enabled (not case sensitive search in option labels & currency props) * if `function` you can define your own search function to implement custom search experience. This search function used while filtering the options array. */ search?: boolean | ((option: SelectItemWithPlaceholder, searchValue: string) => boolean); options: SelectItem[]; /** @default '' */ searchValue?: string; searchPlaceholder?: string; /** @default {} */ classNames?: Record; dropdownUp?: boolean; buttonProps?: Extract; dropdownProps?: React.ComponentPropsWithoutRef<'ul'>; onChange: (value: SelectItem | typeof DEFAULT_SELECTED_OPTION) => void; onFocus?: React.FocusEventHandler; onBlur?: React.FocusEventHandler; /** * To have full control of your search value and response use `onSearchChange` function combined with `searchValue` and custom filtering on the options array. * DO NOT USE TOGETHER WITH `search` PROPERTY */ onSearchChange?: (value: string) => void; } const defaultClassNames = {}; /** * @deprecated Use `SelectInput` instead (https://neptune.wise.design/blog/2023-11-28-adopting-our-new-selectinput) */ export default function Select({ placeholder, id, required, disabled, inverse, dropdownWidth, size = 'md', block = true, selected, search, onChange, onFocus, onBlur, options: defaultOptions, onSearchChange, searchValue: initSearchValue = '', searchPlaceholder, classNames: classNamesProp = defaultClassNames, dropdownUp, dropdownProps, buttonProps, }: SelectProps) { const inputAttributes = useInputAttributes(); const { formatMessage } = useIntl(); const s = (className: string) => classNamesProp[className] || className; const [open, setOpen] = useState(false); const [searchValue, setSearchValue] = useState(DEFAULT_SEARCH_VALUE); const [keyboardFocusedOptionIndex, setKeyboardFocusedOptionIndex] = useState(-1); const keyboardFocusedReference = useRef(null); const previousKeyboardFocusedOptionIndex = useRef(-1); const [numberOfOptionsShown, setNumberOfOptionsShown] = useState(DEFAULT_OPTIONS_PAGE_SIZE); const searchBoxReference = useRef(null); const selectReference = useRef(null); const dropdownButtonReference = useRef(null); const optionsListReference = useRef(null); const isSearchEnabled = !!onSearchChange || !!search; const isDropdownAutoWidth = dropdownWidth == null; const options = useMemo(() => { if (!search || !searchValue) { return defaultOptions; } return defaultOptions.filter(isSearchableOption).filter((option) => { if (typeof search === 'function') { return search(option, searchValue); } return defaultFilterFunction(option, searchValue); }); }, [defaultOptions, search, searchValue]); const selectableOptions = useMemo(() => options.filter(isActionableOption), [options]); const focusedOption = selectableOptions[keyboardFocusedOptionIndex]; const fallbackButtonId = useId(); const computedId = id || inputAttributes.id || fallbackButtonId; const listboxId = `${computedId}-listbox`; const searchBoxId = `${computedId}-searchbox`; const { isMobile } = useLayout(); useEffect(() => { let cancelled = false; if (keyboardFocusedOptionIndex >= 0) { requestAnimationFrame(() => { if (!cancelled) { if (isSearchEnabled) { keyboardFocusedReference.current?.scrollIntoView?.({ block: 'center' }); } else { keyboardFocusedReference.current?.focus(); } } }); return () => { cancelled = true; }; } }, [keyboardFocusedOptionIndex, isSearchEnabled]); const handleOnClick = () => { setOpen(true); }; const handleTouchStart: React.TouchEventHandler = (event) => { if (event.currentTarget === event.target && open) { handleCloseOptions(); } }; const handleOnFocus: React.FocusEventHandler = (event) => { onFocus?.(event); }; const handleOnBlur: React.FocusEventHandler = (event) => { const { nativeEvent } = event; if (nativeEvent) { const elementReceivingFocus = nativeEvent.relatedTarget; const select = event.currentTarget; if ( select && elementReceivingFocus instanceof Node && select.contains(elementReceivingFocus) ) { return; } } onBlur?.(event); }; const handleSearchChange: React.ChangeEventHandler = (event) => { setNumberOfOptionsShown(DEFAULT_OPTIONS_PAGE_SIZE); setSearchValue(event.target.value); onSearchChange?.(event.target.value); }; const handleKeyDown: React.KeyboardEventHandler = (event) => { switch (event.key) { case 'ArrowUp': case 'ArrowDown': if (open) { moveFocusWithDifference(event.key === 'ArrowUp' ? -1 : 1); } else { setOpen(true); } stopPropagation(event); break; case ' ': if (event.target !== searchBoxReference.current) { if (open) { selectKeyboardFocusedOption(); } else { setOpen(true); } stopPropagation(event); } break; case 'Enter': if (open) { selectKeyboardFocusedOption(); } else { setOpen(true); } stopPropagation(event); break; case 'Escape': handleCloseOptions(); stopPropagation(event); break; case 'Tab': if (open) { selectKeyboardFocusedOption(); } break; default: break; } }; function selectKeyboardFocusedOption() { if (keyboardFocusedOptionIndex >= 0 && selectableOptions.length > 0) { selectOption(selectableOptions[keyboardFocusedOptionIndex]); } } function moveFocusWithDifference(difference: number) { const selectedOptionIndex = selectableOptions.reduce((optionIndex, current, index) => { if (optionIndex >= 0) { return optionIndex; } if (isOptionSelected(selected, current)) { return index; } return -1; }, -1); const previousFocusedIndex = previousKeyboardFocusedOptionIndex.current; let indexToStartMovingFrom = previousFocusedIndex; if (previousFocusedIndex < 0) { if (selectedOptionIndex < 0) { setKeyboardFocusedOptionIndex(0); } else { indexToStartMovingFrom = selectedOptionIndex; } } const unClampedNewIndex = indexToStartMovingFrom + difference; const newIndex = clamp(0, selectableOptions.length - 1, unClampedNewIndex); setKeyboardFocusedOptionIndex(newIndex); } useEffect(() => { if (open) { if (!isMobile || searchValue) { if (isSearchEnabled && searchBoxReference.current) { searchBoxReference.current.focus(); } if ( !isSearchEnabled && optionsListReference.current && previousKeyboardFocusedOptionIndex.current < 0 ) { optionsListReference.current.focus(); } } previousKeyboardFocusedOptionIndex.current = keyboardFocusedOptionIndex; } else { previousKeyboardFocusedOptionIndex.current = -1; } }, [open, searchValue, isSearchEnabled, isMobile, keyboardFocusedOptionIndex]); const handleCloseOptions = () => { setOpen(false); setKeyboardFocusedOptionIndex(-1); if (dropdownButtonReference.current) { dropdownButtonReference.current.focus(); } }; function createSelectHandlerForOption(option: SelectItemWithPlaceholder) { return (event: React.SyntheticEvent) => { stopPropagation(event); selectOption(option); }; } function selectOption(option: SelectItemWithPlaceholder) { onChange(isPlaceholderOption(option) ? DEFAULT_SELECTED_OPTION : option); handleCloseOptions(); } function renderOptionsList({ className = '' } = {}) { const dropdownClass = clsx( s('np-dropdown-menu'), { [s('np-dropdown-menu-desktop')]: !isMobile, [s(`np-dropdown-menu-${dropdownWidth}`)]: !isMobile && !isDropdownAutoWidth, }, s(className), ); const showPlaceholder = !required && !isSearchEnabled && Boolean(placeholder); return (
    {showPlaceholder && } {isSearchEnabled && ( )} {options.slice(0, numberOfOptionsShown).map(renderOption)} {numberOfOptionsShown < options.length && }
); } function ShowMoreOption() { function handleOnClick(event: React.SyntheticEvent) { stopPropagation(event); setNumberOfOptionsShown(numberOfOptionsShown + DEFAULT_OPTIONS_PAGE_SIZE); } return ( /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */
  • {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} ...
  • ); } function PlaceHolderOption() { const placeholderOption = { placeholder }; return ( /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */
  • {/* eslint-disable-next-line jsx-a11y/anchor-is-valid */} {placeholder}
  • ); } function SeparatorOption() { return
  • ; } function HeaderOption({ children }: { children?: React.ReactNode }) { return (
  • {children}
  • ); } function isOptionSelected(selected: SelectOptionItem | undefined, option: SelectItem) { return selected?.value === option?.value; } const renderOption = (option: SelectItem, index: number) => { const separatorOption = option; if (isSeparatorOption(separatorOption) && separatorOption?.separator) { return ; } const headerOption = option; if (isHeaderOption(headerOption) && headerOption.header) { return {headerOption.header}; } const isActive = isOptionSelected(selected, option); const selectOption = option; const isFocusedWithKeyboard = !selectOption.disabled && keyboardFocusedOptionIndex === getIndexWithoutHeadersForIndexWithHeaders(index); const className = clsx( s('np-dropdown-item'), selectOption.disabled ? [s('disabled')] : s('clickable'), { [s('active')]: isActive, [s('np-dropdown-item--focused')]: isFocusedWithKeyboard, }, ); const handleOnClick = selectOption.disabled ? stopPropagation : createSelectHandlerForOption(selectOption); return (
  • {/* @ts-expect-error options needs DOM refactoring */}
  • ); }; function getIndexWithoutHeadersForIndexWithHeaders(index: number) { return options.reduce((sum, option, currentIndex) => { if (currentIndex < index && isActionableOption(option)) { return sum + 1; } return sum; }, 0); } const hasActiveOptions = !!defaultOptions.length; if (open && (initSearchValue || searchValue)) { if (hasActiveOptions && keyboardFocusedOptionIndex < 0) { setKeyboardFocusedOptionIndex(0); } if (!hasActiveOptions && keyboardFocusedOptionIndex >= 0) { setKeyboardFocusedOptionIndex(-1); } } return (
    {isMobile ? ( isSearchEnabled ? ( {renderOptionsList()} ) : ( {renderOptionsList()} ) ) : ( {renderOptionsList({ className: 'p-a-1' })} )}
    ); }