import { createContext, useContext, useState, useEffect, useRef, forwardRef, ForwardRefExoticComponent, RefAttributes, useImperativeHandle, ReactNode, Ref, useMemo, useCallback, } from 'react'; import { ScrollbarWrapper, Tooltip } from '../../index'; import { components, GroupTypeBase, OptionTypeBase, ValueContainerProps, } from 'react-select'; import { Icon } from '../icon/Icon.component'; import { SelectStyle } from './SelectStyle'; import { FixedSizeList, FixedSizeList as List } from 'react-window'; import { convertRemToPixels } from '../../utils'; import { spacing } from '../../spacing'; import { convertSizeToRem } from '../inputv2/inputv2'; import { ConstrainedText } from '../constrainedtext/Constrainedtext.component'; import ReactSelect from 'react-select/src/Select'; const ITEMS_PER_SCROLL_WINDOW = 4; // more/equal than NOPT_SEARCH options enable search const NOPT_SEARCH = 8; export type OptionProps = { title?: string; disabled?: boolean; icon?: ReactNode; children?: ReactNode; value: string; disabledReason?: ReactNode; }; const usePreviousValue = (value) => { const ref = useRef(null); useEffect(() => { ref.current = value; }); return ref.current; }; function useOptions() { const optionContext = useContext(OptionContext); if (!optionContext) throw new Error( 'useOptions cannot be rendered outside the Select component', ); return Object.values(optionContext.options); } export function Option({ value, children, disabled, icon, disabledReason, ...rest }: OptionProps): JSX.Element { const optionContext = useContext(OptionContext); if (!optionContext) throw new Error('Option cannot be rendered outside the Select component'); const prevValue = usePreviousValue(value); useEffect(() => { if (prevValue && prevValue !== value) { optionContext.unregister(prevValue); } optionContext.register({ value: value, label: children || '', isDisabled: disabled || false, icon: icon, disabledReason: disabledReason, optionProps: { ...rest }, }); return () => { optionContext.unregister(value); }; //eslint-disable-next-line react-hooks/exhaustive-deps -- optionContext is mutable }, [children, disabled, icon, value, prevValue]); return <>; } const Input = (props) => { return ; }; const selectDropdownIndicator = ( caretType: 'chevron' | 'caret', indicatorDirection: 'up' | 'down', ) => { if (caretType === 'chevron') { if (indicatorDirection === 'up') return 'Chevron-up'; else return 'Chevron-down'; } else { if (indicatorDirection === 'up') return 'Dropdown-up'; else return 'Dropdown-down'; } }; const DropdownIndicator = (props) => { const indicatorDirection = props.selectProps.menuIsOpen ? 'up' : 'down'; const caretType = props.selectProps.isDefault ? 'chevron' : 'caret'; return ( ); }; const InternalOption = (width, isDefaultVariant) => (props) => { const formatOptionLabel = () => { const label: string = props.data.label; const inputValue = props.selectProps.inputValue; const parts = label .split(inputValue) .flatMap((item, index) => [inputValue, item]) .slice(1); const reducedWidth = `${parseFloat(width.replace('rem')) - 2}rem`; if (inputValue) { return ( { const highlightStyle = part.toLowerCase() === inputValue.toLowerCase() ? 'sc-highlighted-matching-text' : ''; return ( {part} ); })} /> ); } else { return ( ); } }; const innerProps = { ...props.innerProps, ...props.data.optionProps, // remove onMouseMove & onMouseOver so that options are not focused on hover onMouseMove: undefined, onMouseOver: undefined, role: 'option', 'aria-disabled': props.isDisabled, 'aria-selected': props.isSelected, }; return (
{props.data.icon}
{formatOptionLabel()}
{props.isDisabled && }
); }; const Menu = (props) => { useEffect(() => { props.selectProps.setIsMenuBottom(props.placement === 'bottom'); }, [props]); return ; }; const getScrollOffset = ( list, index: number, itemCount: number, offset: number, ): number => { const { itemSize, height } = list.props; const scrollOffset = list.state ? list.state.scrollOffset : 0; const lastItemOffset = Math.max(0, itemCount * itemSize - height); const maxOffset = Math.min(lastItemOffset, index * itemSize); const minOffset = Math.max(0, index * itemSize - height + itemSize); if (scrollOffset >= minOffset && scrollOffset <= maxOffset) { return scrollOffset; } else if (scrollOffset < minOffset) { return minOffset === 0 ? minOffset : minOffset + offset; } else { return maxOffset === 0 ? maxOffset : maxOffset - offset; } }; const MenuList = (props) => { const listRef = useRef | null>(null); const { children, getValue } = props; const [selectedOption] = getValue(); const optionHeight = convertRemToPixels( parseFloat(props.selectProps.isDefault ? spacing.r40 : spacing.r24), ) || 32; let selectedIndex = 0; let focusedIndex = 0; if (children && children.length > 0) { selectedIndex = children.findIndex( (child) => child.props.data === selectedOption, ); focusedIndex = props.focusedOption ? children.findIndex((child) => child.props.data === props.focusedOption) : selectedIndex; } const initialOffset = selectedIndex * optionHeight - (ITEMS_PER_SCROLL_WINDOW - 1) * optionHeight; useEffect(() => { if (listRef && listRef.current) { listRef.current.scrollTo( getScrollOffset( listRef.current, focusedIndex, children.length, optionHeight / 2, ), ); } }, [children.length, focusedIndex, optionHeight, listRef]); if (children.length > ITEMS_PER_SCROLL_WINDOW) { return ( // @ts-ignore {({ index, style }) => { return (
{children[index]}
); }}
); } return {children}; }; const ValueContainer = < OptionType extends OptionTypeBase, IsMulti extends boolean, GroupType extends GroupTypeBase, >({ children, ...props }: ValueContainerProps) => { const selectedOption = props.selectProps.selectedOption; const icon = selectedOption ? selectedOption.icon : null; const ariaProps = { innerProps: { disabled: true, role: props.selectProps.isSearchable ? 'combobox' : 'listbox', 'aria-expanded': props.selectProps.menuIsOpen, 'aria-autocomplete': 'list', 'aria-label': props.selectProps.placeholder, }, }; return ( {icon ?
{icon}
: null}
{children}
); }; export interface SelectRef< OptionType extends OptionTypeBase, IsMulti extends boolean, GroupType extends GroupTypeBase, > { select: ReactSelect | null; focus: () => void; blur: () => void; openMenu: () => void; closeMenu: () => void; setValue: (value: string) => void; clearValue: () => void; } export type SelectProps = { id: string; placeholder?: string; disabled?: boolean; children?: ReactNode; value?: string; onFocus?: (event: FocusEvent) => void; onBlur?: (event: FocusEvent) => void; onChange: (newValue: string) => void; variant?: 'default' | 'rounded'; size?: '1' | '2/3' | '1/2' | '1/3'; className?: string; /** use menuPositon='fixed' inside modal to avoid display issue */ menuPosition?: 'fixed' | 'absolute'; }; type SelectOptionProps = { value: string; label: ReactNode; isDisabled: boolean; icon?: ReactNode; optionProps: any; disabledReason?: ReactNode; }; type SelectComponentType< OptionType extends OptionTypeBase, IsMulti extends boolean, GroupType extends GroupTypeBase, > = ForwardRefExoticComponent< SelectProps & RefAttributes> > & { Option: typeof Option; }; const OptionContext = createContext<{ options: Record; register: (option: SelectOptionProps) => void; unregister: (value: string) => void; } | null>(null); function SelectBox< OptionType extends OptionTypeBase, IsMulti extends boolean, GroupType extends GroupTypeBase, >({ placeholder = 'Select...', disabled = false, value, onChange, variant = 'default', className, size = '1', id, selectRef, ...rest }: SelectProps & { selectRef?: Ref>; }) { const [keyboardFocusEnabled, setKeyboardFocusEnabled] = useState(false); const [searchSelection, setSearchSelection] = useState(''); const [searchValue, setSearchValue] = useState(''); const [customPlaceholder, setPlaceholder] = useState(placeholder); const isDefaultVariant = variant === 'default'; const [isMenuBottom, setIsMenuBottom] = useState(true); const internalSelectRef = useRef< ReactSelect & { setState: (state: { menuIsOpen: boolean }) => void; state: { isOpen: boolean }; select: { setValue: (option: SelectOptionProps) => void; clearValue: () => void; }; } >(null); useImperativeHandle( selectRef, () => ({ focus: () => { if (internalSelectRef.current) { internalSelectRef.current.focus(); } }, blur: () => { if (internalSelectRef.current) { internalSelectRef.current.blur(); } }, select: internalSelectRef.current, openMenu: () => { if (internalSelectRef.current) { internalSelectRef.current.setState({ menuIsOpen: true }); } }, closeMenu: () => { if (internalSelectRef.current) { internalSelectRef.current.setState({ menuIsOpen: false }); } }, setValue: (newValue: string) => { if (internalSelectRef.current) { const option = options.find((opt) => opt.value === newValue); if (option) { internalSelectRef.current.select.setValue(option); } } }, clearValue: () => { if (internalSelectRef.current && internalSelectRef.current.select) { internalSelectRef.current.select.clearValue(); } }, }), [internalSelectRef], ); const options = useOptions(); const handleChange = (option: SelectOptionProps) => { const newValue = option ? option.value : ''; if (onChange && typeof onChange === 'function' && newValue !== value) { onChange(newValue); } if (options && options.length > NOPT_SEARCH && internalSelectRef.current) { internalSelectRef.current.blur(); } }; const handleSearchInput = (inputValue, { action }) => { if (options && options.length > NOPT_SEARCH) { if (action === 'menu-close') { setSearchSelection(''); } if (action === 'input-blur' || action === 'set-value') { if (searchValue) setPlaceholder(searchValue); else setPlaceholder(placeholder); setSearchValue(inputValue); } else { setSearchValue(inputValue); if (inputValue.length === 0) setPlaceholder(placeholder); } } }; const isEmptyStringInOptions = options.find((option) => option.value === ''); // Force to reset the value useEffect(() => { if ( !isEmptyStringInOptions && value === '' && internalSelectRef.current && internalSelectRef.current.select ) { internalSelectRef.current.select.clearValue(); } }, [value, isEmptyStringInOptions]); return ( <> {options && ( opt.value === value) } inputValue={options.length > NOPT_SEARCH ? searchValue : undefined} selectedOption={options.find((opt) => opt.value === value)} keyboardFocusEnabled={keyboardFocusEnabled} options={options} isDisabled={disabled} placeholder={customPlaceholder} menuPlacement="auto" isSearchable={options.length > NOPT_SEARCH} components={{ Input: Input, Option: InternalOption(convertSizeToRem(size), isDefaultVariant), Menu: Menu, MenuList: MenuList, ValueContainer: ValueContainer, DropdownIndicator: DropdownIndicator, IndicatorSeparator: null, }} isDefault={isDefaultVariant} ITEMS_PER_SCROLL_WINDOW={ITEMS_PER_SCROLL_WINDOW} onChange={handleChange} onInputChange={handleSearchInput} ref={internalSelectRef} isMenuBottom={isMenuBottom} setIsMenuBottom={setIsMenuBottom} onBlur={rest.onBlur} onFocus={rest.onFocus} onMenuClose={() => setKeyboardFocusEnabled(false)} onKeyDown={(event: KeyboardEvent) => { if ( event && event.key === 'Enter' && internalSelectRef.current && !internalSelectRef.current.state.isOpen ) { internalSelectRef.current.setState({ menuIsOpen: true, }); } else { setKeyboardFocusEnabled(true); } }} width={convertSizeToRem(size)} {...rest} /> )} ); } const SelectWithOptionContext = forwardRef< SelectRef>, SelectProps >((props, ref) => { const [options, setOptions] = useState>({}); const register = useCallback((option: SelectOptionProps) => { setOptions((prevOptions) => ({ ...prevOptions, [option.value]: option, })); }, []); const unregister = useCallback((value: string) => { setOptions((prevOptions) => { const { [value]: _, ...rest } = prevOptions; return rest; }); }, []); const contextValue = useMemo(() => ({ options, register, unregister }), [options, register, unregister]); return ( <> {props.children} ); }) as SelectComponentType< OptionTypeBase, boolean, GroupTypeBase >; SelectWithOptionContext.displayName = 'Select'; SelectWithOptionContext.Option = Option; export const Select = SelectWithOptionContext;