Combobox Component

An input component for selecting an option from a popup list; options are generated dynamically as the input changes.

Interface

declare namespace GridCombobox {
        interface OptionCell {
                id: string;
                text: string;
        }
        interface Option {
                id: string;
                cells: OptionCell[];
        }
        interface ComboxProps<T>
                extends React.HTMLAttributes<HTMLInputElement> {
                setOptions: React.Dispatch<
                        React.SetStateAction<T[] | undefined>
                >;
                activeOption: T | undefined;
                activeCellID: string;
        }
        interface GridPopupProps<T extends Option>
                extends React.HTMLAttributes<HTMLDivElement> {
                options: T[] | undefined;
                activeOption: T | undefined;
                activeCellID: string;
        }

        interface LabelProps extends React.HTMLAttributes<HTMLLabelElement> {
                htmlFor: string;
        }
        interface Props<T extends Option> {
                Combobox: React.FC<ComboxProps<T>>;
                GridPopup: React.FC<GridPopupProps<T>>;
                Label: React.FC<LabelProps>;
                labelID: string;
                comboboxID: string;
                gridPopupID: string;
        }
}

Example Implementation

function GridCombobox<T extends GridCombobox.Option>({
        Label,
        Combobox,
        GridPopup,
        labelID,
        comboboxID,
        gridPopupID,
}: GridCombobox.Props<T>) {
        const [options, setOptions] = useState<T[]>();
        const [activeOption, setActiveOption] = useState<T>();
        const [activeCellID, setActiveCellID] = useState("");

        function setCellID(option: T) {
                if (!activeOption) {
                        setActiveCellID(option.cells[0].id);
                        return;
                }
                const activeCellIndex = activeOption.cells.findIndex(
                        (c) => c.id === activeCellID
                );
                setActiveCellID(option.cells[activeCellIndex].id);
        }
        function onComboboxKeyDown(e: KeyboardEvent<HTMLInputElement>) {
                if (!options) return;
                const { key } = e;
                const activeIndex = options.findIndex(
                        (o) => o.id === activeOption?.id
                );
                if (key === KeyMap.ArrowDown) {
                        e.preventDefault();
                        const newIndex = activeIndex + 1;
                        if (newIndex > options.length - 1) {
                                const option = options[0];
                                setActiveOption(option);
                                setCellID(option);
                                return;
                        }
                        const option = options[newIndex];
                        setActiveOption(option);
                        setCellID(option);
                }
                if (key === KeyMap.ArrowUp) {
                        e.preventDefault();
                        const newIndex = activeIndex - 1;
                        if (newIndex < 0) {
                                const option = options[options.length - 1];
                                setActiveOption(option);
                                setCellID(option);
                                return;
                        }
                        const option = options[newIndex];
                        setActiveOption(options[newIndex]);
                        setCellID(option);
                }
                if (key === KeyMap.ArrowLeft) {
                        e.preventDefault();
                        if (!activeOption) return;
                        const activeCellIndex = activeOption.cells.findIndex(
                                (c) => c.id === activeCellID
                        );
                        const newIndex = activeCellIndex - 1;
                        if (newIndex < 0) {
                                setActiveCellID(
                                        activeOption.cells[
                                                activeOption.cells.length - 1
                                        ].id
                                );
                                return;
                        }
                        setActiveCellID(activeOption.cells[newIndex].id);
                }
                if (key === KeyMap.ArrowRight) {
                        e.preventDefault();
                        if (!activeOption) return;
                        const activeCellIndex = activeOption.cells.findIndex(
                                (c) => c.id === activeCellID
                        );
                        const newIndex = activeCellIndex + 1;
                        if (newIndex > activeOption.cells.length - 1) {
                                setActiveCellID(activeOption.cells[0].id);
                                return;
                        }
                        setActiveCellID(activeOption.cells[newIndex].id);
                }
                if (key === KeyMap.Esc) {
                        e.preventDefault();
                        if (options) {
                                setOptions(undefined);
                        }
                }
        }
        function onKeyUp(e: KeyboardEvent<HTMLInputElement>) {
                if (!e.currentTarget.value) {
                        setOptions(undefined);
                }
        }
        return (
                <div style={{ position: "relative" }}>
                        <Label id={labelID} htmlFor={comboboxID} />
                        <div>
                                <Combobox
                                        id={comboboxID}
                                        onKeyDown={onComboboxKeyDown}
                                        onKeyUp={onKeyUp}
                                        setOptions={setOptions}
                                        activeOption={activeOption}
                                        activeCellID={activeCellID}
                                        aria-autocomplete="list"
                                        aria-controls={gridPopupID}
                                        aria-haspopup="grid"
                                        aria-expanded={!!options}
                                        aria-activedescendant={activeCellID}
                                />
                                <GridPopup
                                        aria-labelledby={labelID}
                                        id={gridPopupID}
                                        options={options}
                                        activeOption={activeOption}
                                        activeCellID={activeCellID}
                                />
                        </div>
                </div>
        );
}

Combobox