) => {
event.persist();
setFilter(event.target.value);
setShouldFilter(true);
setOpen((prevOpen) => {
setFocusedIndex((prevFocusIndex) => (prevOpen ? prevFocusIndex : -1));
return true;
});
};
/** Helper function to determine the next array index going forward or backward, including wrapping */
const getNextIndex = (index: number, arrayLength: number, next: boolean = true) => {
let i = index;
const limit = arrayLength - 1;
if (limit === 0) {
i = 0;
} else if (i === limit) {
i = next ? 0 : i - 1;
} else if (i === -1 || i === 0) {
i = next ? i + 1 : limit;
} else if (i < limit) {
i = next ? i + 1 : i - 1;
} else if (i > limit) {
i = next ? 0 : limit;
}
return i;
};
/** Function to get the index of the next or previous focusable option, taking into account disabled options */
const getAvailableFocusedIndex = (next: boolean = true) => {
const len = filteredOptions.length;
let i = getNextIndex(focusedIndex, len, next);
let option = filteredOptions[i];
while (option && i !== focusedIndex && option.disabled) {
i = len === 1 && option.disabled ? focusedIndex : getNextIndex(i, len, next);
option = filteredOptions[i];
}
return i;
};
/** Handler for keydown events on the container, allowing for accessible keyboard navigation */
const onContainerKeyDown = (event: React.KeyboardEvent) => {
switch (event.key) {
case ' ':
if (!open) {
setOpen(true);
setFocusedIndex(-1);
}
break;
case 'Escape':
event.stopPropagation();
setOpen(false);
inputRef.current?.blur();
break;
case 'Enter':
if (!open) {
setOpen(true);
} else {
const option = filteredOptions[focusedIndex];
if (unfilteredOptions.includes(option)) {
selectOption(event, filteredOptions[focusedIndex].value);
inputRef.current?.blur();
}
}
break;
case 'ArrowUp':
event.preventDefault();
setOpen(true);
setFocusedIndex(getAvailableFocusedIndex(false));
break;
case 'ArrowDown':
event.preventDefault();
setOpen(true);
setFocusedIndex(getAvailableFocusedIndex());
break;
case 'Tab':
event.stopPropagation();
setOpen(false);
break;
default:
}
};
// RENDER FUNCTIONS, most if not all taken from FlySelect:
const renderPlaceholder = () => {
if (!optionsLoaded || loadingOptions) {
return loadingOptionsPlaceholder;
}
if (unfilteredOptions.length) {
return placeholder;
}
return emptyPlaceholder;
};
const renderItemRight = (option: ComboboxOptionFormatted, showCheck: boolean) => {
return (
{option.secondaryText && (
{option.secondaryText}
)}
{showCheck && option.value === currentValue && (
)}
);
};
const renderItem = (option: ComboboxOptionFormatted, showCheck: boolean = false) => {
const output = [];
if (option.download === true) {
output.push(
);
}
if (option.icon) {
if (typeof option.icon === 'string') {
// eslint-disable-next-line jsx-a11y/alt-text
output.push(
);
} else {
output.push(
React.cloneElement(option.icon as React.ReactElement, {
key: `${id}-${option.value}-icon`,
className: styles.Combobox__ItemIcon,
})
);
}
}
output.push(
{option.label}
);
output.push(renderItemRight(option, showCheck));
return output;
};
const renderOption = (
option: ComboboxOptionFormatted,
optionGroup?: ComboboxOptionGroupFormatted
): React.ReactNode => {
if (option.optionGroup !== optionGroup?.name) {
return null;
}
const isFocused = filteredOptions.indexOf(option) === focusedIndex;
return (
{}}
onMouseDown={(e) => {
if (option.disabled) {
e.preventDefault();
}
}}
onClick={(e) => {
if (option.disabled) {
e.stopPropagation();
return
}
selectOption(e, option.value)
}}
onMouseEnter={() => setFocusedIndex(filteredOptions.indexOf(option))}
ref={(el) => {
if (isFocused) {
el?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
}
}}
>
{renderItem(option, true)}
);
};
const renderOptionGroups = (opts: ComboboxOptionsFormatted) => {
if (!formattedOptionGroups) {
return null;
}
const output: React.ReactNode[] = [];
formattedOptionGroups.forEach((optionGroup) => {
const optionNodes = opts.map((option) => renderOption(option, optionGroup)).filter((n) => n);
if (!optionNodes.length) {
return;
}
output.push(
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */}
{optionGroup.label}
{optionGroup.linkText && optionGroup.href ? (
{optionGroup.linkText}
) : null}
{' '}
{/* note: this is here to ensure that the expected alternating row color order is maintained */}
);
output.push(optionNodes);
});
return output;
};
// HELPERS:
const clearFilter = () => {
setFilter('');
inputRef.current?.focus();
};
const preventFocus = (event: React.MouseEvent) => {
event.persist();
event.preventDefault();
};
/** Helper function necessary because the height of BasicInput can change based on the invalid message or inputHeight prop */
const getIconTop = (iconHeight: number) => ({
top: inputRef.current ? `${(inputRef.current.clientHeight - iconHeight) / 2}px` : '0',
});
const currentOption = unfilteredOptions.find((opt) => opt.value === currentValue);
return (
{(showOptionIconWhenSelected && currentOption?.icon) && React.cloneElement(currentOption.icon as React.ReactElement, {
className: styles.Combobox__CurrentOptionIcon,
'aria-hidden': true,
height: 22,
style: getIconTop(22),
})}
{open ? (
filter && (
)
) : (
)}
{filteredOptions.map((option) => renderOption(option))}
{renderOptionGroups(filteredOptions)}
);
};
export default Combobox;