import { isTypingInEditableElement } from '@/utils/misc'; import { useCallback, useEffect, useRef, useState } from 'react'; import TextToken from '../../webpages/dashboard/components/TextToken'; export default function CoopSelect(props: { options: { value: T; label: string; }[]; mode?: 'single' | 'multiselect'; placeholder?: string; disabled?: boolean; value?: T | T[]; onSelect?: (value: T) => void; onDeselect?: (value: T) => void; openDropdown?: () => void; onDropdownVisibleChange?: (visible: boolean) => void; openDropdownKeyBinding?: Exclude< string, 'Escape' | 'Enter' | 'ArrowUp' | 'ArrowDown' >; }) { const { options, mode = 'single', placeholder, disabled = false, value, onSelect, onDeselect, onDropdownVisibleChange, openDropdownKeyBinding, } = props; const [isMenuVisible, setIsMenuVisible] = useState(false); const [selectedOptions, setSelectedOptions] = useState( !value ? [] : Array.isArray(value) ? value : [value], ); const [focusedOption, setFocusedOption] = useState(undefined); const [searchString, setSearchString] = useState( undefined, ); const componentRef = useRef(null); const inputEl = useRef(null); const onClick = () => { if (disabled) { return; } if (inputEl.current != null) { inputEl.current.focus(); } setIsMenuVisible(true); }; const toggleOption = useCallback( (val: T) => { setSelectedOptions((prev) => { if (mode === 'single') { setIsMenuVisible(false); } if (prev.includes(val)) { if (onDeselect) { onDeselect(val); } return prev.filter((it) => it !== val); } else { if (onSelect) { onSelect(val); } return [...prev, val]; } }); setSearchString(undefined); }, [mode, onDeselect, onSelect], ); // Scroll to an option when it's focused useEffect(() => { if (isMenuVisible && focusedOption) { const menuElement = document.getElementById('menu'); const optionElement = document.getElementById(focusedOption); if (!menuElement || !optionElement) { return; } const menuRect = menuElement.getBoundingClientRect(); const optionRect = optionElement.getBoundingClientRect(); const isOptionVisible = optionRect.top >= menuRect.top && optionRect.bottom <= menuRect.bottom && optionRect.left >= menuRect.left && optionRect.right <= menuRect.right; // Check if the component is not fully in view if (!isOptionVisible) { optionElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center', }); } } }, [isMenuVisible, focusedOption]); useEffect(() => { if (isMenuVisible) { setFocusedOption(options[0].value); } if (onDropdownVisibleChange) { onDropdownVisibleChange(isMenuVisible); } // Only run this when isMenuVisible changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [isMenuVisible]); useEffect(() => { setSelectedOptions(!value ? [] : Array.isArray(value) ? value : [value]); }, [value]); // Close the menu on outside click useEffect(() => { const handleOutsideClick = (event: MouseEvent) => { if ( componentRef.current && !componentRef.current.contains(event.target as Node) ) { if (isMenuVisible) { setIsMenuVisible(false); } } }; if (isMenuVisible) { document.addEventListener('click', handleOutsideClick); } return () => { document.removeEventListener('click', handleOutsideClick); }; }, [isMenuVisible, options]); // Close the menu when Escape is pressed useEffect(() => { const handleKeyPress = (event: KeyboardEvent) => { if (disabled) { return; } if (isTypingInEditableElement(event.target)) { return; } if (!isMenuVisible) { if (event.key === openDropdownKeyBinding) { setIsMenuVisible(true); event.preventDefault(); inputEl.current?.focus(); } return; } if (event.key === 'Escape') { setIsMenuVisible(false); inputEl.current?.blur(); } if (focusedOption) { if (event.key === 'Enter') { toggleOption(focusedOption); } else { const index = options.findIndex((op) => op.value === focusedOption); if (event.key === 'ArrowUp') { if (index === 0) { setFocusedOption(options[options.length - 1].value); } else { setFocusedOption(options[index - 1].value); } } else if (event.key === 'ArrowDown') { if (index === options.length - 1) { setFocusedOption(options[0].value); } else { setFocusedOption(options[index + 1].value); } } } } }; // Add the event listener when the component mounts window.addEventListener('keydown', handleKeyPress); // Remove the event listener when the component unmounts return () => { window.removeEventListener('keydown', handleKeyPress); }; }, [ disabled, focusedOption, isMenuVisible, openDropdownKeyBinding, options, toggleOption, ]); return (
{selectedOptions.map((option, idx) => { return ( it.value === option)!.label} key={idx} onDelete={() => toggleOption(option)} disabled={disabled ?? false} /> ); })}
{ // If the mode is multiselect or if it's single but there is no // selected option yet, show the input (mode === 'multiselect' || selectedOptions.length === 0) && ( { if ( event.key === 'Backspace' && !searchString?.length && selectedOptions.length > 0 ) { toggleOption(selectedOptions[selectedOptions.length - 1]); } }} onChange={(event) => setSearchString(event.target.value)} placeholder={ selectedOptions.length > 0 || placeholder == null ? '' : placeholder } ref={inputEl} value={searchString} disabled={disabled} /> ) } {isMenuVisible ? ( ) : null}
); }