Select Component

An input component for selecting an option from a list; options are pre-loaded into the component.

Interface

declare namespace Select {
        interface Option {
                id: string;
                text: string;
        }

        interface LabelProps extends React.HTMLAttributes<HTMLDivElement> {}
        interface ComboBoxProps extends React.HTMLAttributes<HTMLDivElement> {
                selectedOption: Option | undefined;
                isOpen: boolean;
        }
        interface ListBoxProps extends React.HTMLAttributes<HTMLDivElement> {}

        interface OptionProps extends React.HTMLAttributes<HTMLDivElement> {
                option: Option;
                isActive: boolean;
        }
        interface Props extends React.HTMLAttributes<HTMLDivElement> {
                onSelectOption?({
                        index,
                        option,
                }: {
                        index: number;
                        option: Option;
                }): void;
                id: string;
                initialValue?: Option;
                options: Option[];

                Label?: React.FC<LabelProps>;
                ComboBox: React.FC<ComboBoxProps>;
                ListBox: React.FC<ListBoxProps>;
                Option: React.FC<OptionProps>;
        }
}

Example Implementation

function Select({
        initialValue,
        options,
        onSelectOption,
        id,
        Label = () => <></>,
        ComboBox,
        ListBox,
        Option,
        ...rest
}: Select.Props) {
        const [selectedOption, setSelectedOption] = useState<Select.Option>();
        const [activeOptionIndex, setActiveOptionIndex] = useState<number>(0);
        const [search, setSearch] = useState("");
        const [isComboOpen, setComboOpen] = useState(false);

        useEffect(() => {
                if (!initialValue) return;
                const initialIndex = options.findIndex(
                        (o) => o.id === initialValue.id
                );
                setSelectedOption(initialValue);
                setActiveOptionIndex(initialIndex === -1 ? 0 : initialIndex);
                // eslint-disable-next-line react-hooks/exhaustive-deps
        }, [initialValue]);

        useEffect(() => {
                if (!search) {
                        return;
                }
                const orderedOptions = [
                        ...options.slice(activeOptionIndex + 1),
                        ...options.slice(0, activeOptionIndex + 1),
                ];
                const filtered = orderedOptions.filter((option) => {
                        return (
                                option.text
                                        .toLowerCase()
                                        .indexOf(search.toLowerCase()) === 0
                        );
                });
                if (filtered.length) {
                        const match = filtered[0];
                        const activeIndex = options.findIndex(
                                (option) => option.id === match.id
                        );
                        setActiveOptionIndex(activeIndex);
                }
                const timeout = setTimeout(() => {
                        setSearch("");
                }, 500);
                return () => clearTimeout(timeout);
                // eslint-disable-next-line react-hooks/exhaustive-deps
        }, [search]);

        useEffect(() => {
                const listBox = document.getElementById({id}-options);
                if (!listBox) return;
                const activeOptionEl = [...listBox.children].find(
                        (optionEl) =>
                                optionEl.id ===
                                {id}-option-{activeOptionIndex}
                );
                if (!activeOptionEl) {
                        return;
                }
                activeOptionEl.scrollIntoView({
                        behavior: "smooth",
                        block: "nearest",
                });
        }, [activeOptionIndex, id, isComboOpen]);

        const selectOption = (index: number, option: Select.Option) => {
                setActiveOptionIndex(index);
                setSelectedOption(option);
                setComboOpen(false);
                onSelectOption && onSelectOption({ index, option });
        };

        function onKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
                const { key, altKey } = e;
                const max = options.length - 1;
                const min = 0;
                if (
                        !isComboOpen &&
                        (key === KeyMap.ArrowDown ||
                                key === KeyMap.ArrowUp ||
                                key === KeyMap.Space ||
                                key === KeyMap.Enter)
                ) {
                        e.preventDefault();
                        setComboOpen(true);
                        e.currentTarget.focus();
                        return;
                }
                if (!isComboOpen && key === KeyMap.Home) {
                        e.preventDefault();
                        setComboOpen(true);
                        setActiveOptionIndex(0);
                        return;
                }
                if (!isComboOpen && key === KeyMap.End) {
                        e.preventDefault();
                        setComboOpen(true);
                        setActiveOptionIndex(options.length - 1);
                        return;
                }

                if (isComboOpen && key === KeyMap.Enter) {
                        selectOption(
                                activeOptionIndex,
                                options[activeOptionIndex]
                        );
                        return;
                }
                if (key === KeyMap.ArrowDown) {
                        e.preventDefault();
                        const nextIndex = activeOptionIndex + 1;
                        setActiveOptionIndex(nextIndex > max ? max : nextIndex);
                        return;
                }
                if (altKey && key === KeyMap.ArrowUp) {
                        e.preventDefault();
                        selectOption(
                                activeOptionIndex,
                                options[activeOptionIndex]
                        );
                        e.currentTarget.focus();
                        setComboOpen(false);
                }
                if (key === KeyMap.ArrowUp) {
                        e.preventDefault();
                        const nextIndex = activeOptionIndex - 1;
                        setActiveOptionIndex(nextIndex < min ? min : nextIndex);
                        return;
                }
                if (key === KeyMap.Esc) {
                        setComboOpen(false);
                        return;
                }
                if (/[w]/.test(key)) {
                        setComboOpen(true);
                        setSearch((search) => {search}{key});
                }
        }

        function onBlur(e: React.FocusEvent<HTMLDivElement, Element>) {
                const relatedTarget = e.relatedTarget as HTMLElement | null;
                if (relatedTarget?.id === {id}-options) {
                        return;
                }
                setComboOpen(false);
        }

        function onComboboxClick() {
                setComboOpen(!isComboOpen);
        }

        return (
                <div {...rest}>
                        <Label id={{id}-label} />
                        <div style={{ position: "relative" }}>
                                <ComboBox
                                        aria-controls={{id}-options}
                                        aria-expanded="false"
                                        aria-haspopup="listbox"
                                        aria-labelledby={{id}-label}
                                        id={{id}-combo}
                                        role="combobox"
                                        tabIndex={0}
                                        onClick={onComboboxClick}
                                        onBlur={onBlur}
                                        onKeyDown={onKeyDown}
                                        selectedOption={selectedOption}
                                        isOpen={isComboOpen}
                                />
                                {isComboOpen && (
                                        <ListBox
                                                role="listbox"
                                                id={{id}-options}
                                                aria-labelledby={{id}-label}
                                                aria-activedescendant={{id}-option-{activeOptionIndex}}
                                                tabIndex={-1}
                                        >
                                                {options.map(
                                                        (option, index) => {
                                                                function onClickOption() {
                                                                        selectOption(
                                                                                index,
                                                                                option
                                                                        );
                                                                }
                                                                return (
                                                                        <Option
                                                                                role="option"
                                                                                id={{id}-option-{index}}
                                                                                aria-selected={
                                                                                        option.id ===
                                                                                        selectedOption?.id
                                                                                }
                                                                                key={
                                                                                        option.id
                                                                                }
                                                                                onClick={
                                                                                        onClickOption
                                                                                }
                                                                                isActive={
                                                                                        index ===
                                                                                        activeOptionIndex
                                                                                }
                                                                                option={
                                                                                        option
                                                                                }
                                                                        />
                                                                );
                                                        }
                                                )}
                                        </ListBox>
                                )}
                        </div>
                </div>
        );
}

Select