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>
);
}