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