import clsx from 'clsx/lite'; import { ReactNode, useEffect, useRef, useState } from 'react'; import MaskedScroll from './MaskedScroll'; import useClickInsideOutside from '@/utility/useClickInsideOutside'; import IconSelectChevron from './icons/IconSelectChevron'; import SelectMenuOption, { SelectMenuOptionType } from './SelectMenuOption'; const LISTENER_KEY_MOUSE_MOVE = 'mousemove'; const LISTENER_KEY_KEYDOWN = 'keydown'; export default function SelectMenu({ id, name, value, className, onChange, options, defaultOptionLabel, tabIndex, error, readOnly, children, }: { id?: string name: string value: string className?: string onChange?: (value: string) => void options: SelectMenuOptionType[] defaultOptionLabel?: string tabIndex?: number error?: string readOnly?: boolean children?: ReactNode }) { const ARIA_ID_SELECT_OPTIONS = `select-options-${name}`; const ref = useRef(null); const [isOpen, setIsOpen] = useState(false); const [selectedOptionIndex, setSelectedOptionIndex] = useState(); const [shouldHighlightOnHover, setShouldHighlightOnHover] = useState(true); const selectedOption = options.find(o => o.value === value); useClickInsideOutside({ htmlElements: [ref], onClickOutside: () => setIsOpen(false), }); useEffect(() => { if (readOnly) { setIsOpen(false); } }, [readOnly]); // Setup keyboard listener useEffect(() => { const listener = (e: KeyboardEvent) => { // Keys which always trap focus switch (e.key) { case 'ArrowDown': case 'ArrowUp': case 'Escape': setShouldHighlightOnHover(false); e.stopImmediatePropagation(); e.preventDefault(); } // Navigate options switch (e.key) { case 'ArrowDown': if (isOpen) { setSelectedOptionIndex(i => { if (i === undefined) { return options.length > 1 ? 1 : 0; } else if (i >= options.length - 1) { return 0; } else { return i + 1; } }); } else { setIsOpen(true); setSelectedOptionIndex(0); } break; case 'ArrowUp': if (isOpen) { setSelectedOptionIndex((i = 0) => { if (options.length > 1) { if (i === 0) { return options.length - 1; } else { return i - 1; } } }); } else { setIsOpen(true); setSelectedOptionIndex(Math.max(0, options.length - 1)); } break; case 'Enter': if (isOpen) { if (selectedOptionIndex !== undefined) { onChange?.(options[selectedOptionIndex].value); } setIsOpen(false); } break; case 'Escape': setIsOpen(false); break; } }; const refRef = ref.current; refRef?.addEventListener(LISTENER_KEY_KEYDOWN, listener); return () => refRef?.removeEventListener(LISTENER_KEY_KEYDOWN, listener); }, [ isOpen, options, selectedOptionIndex, onChange, ]); useEffect(() => { const onMouseMove = () => { setShouldHighlightOnHover(true); }; const refRef = ref.current; refRef?.addEventListener(LISTENER_KEY_MOUSE_MOVE, onMouseMove); return () => refRef?.removeEventListener(LISTENER_KEY_MOUSE_MOVE, onMouseMove); }, []); return (
setIsOpen(o => !o)} onFocus={() => setIsOpen(true)} onBlur={e => { if (e.relatedTarget && !ref.current?.contains(e.relatedTarget)) { setIsOpen(false); } }} aria-autocomplete="list" aria-expanded={isOpen} aria-haspopup="true" aria-controls={isOpen ? ARIA_ID_SELECT_OPTIONS : undefined} role="combobox" > {children ??
}
{isOpen &&
{defaultOptionLabel && } {options.map((option, index) => { onChange?.(option.value); setIsOpen(false); }} />)}
}
); }