@itwin/itwinui-react
Version:
A react component library for iTwinUI
446 lines (445 loc) • 14 kB
JavaScript
import * as React from 'react';
import { MenuExtraContent } from '../Menu/MenuExtraContent.js';
import { SelectTag } from '../Select/SelectTag.js';
import { Text } from '../Typography/Text.js';
import {
mergeRefs,
useLatestRef,
useLayoutEffect,
AutoclearingHiddenLiveRegion,
useId,
useControlledState,
} from '../../utils/index.js';
import { usePopover } from '../Popover/Popover.js';
import { ComboBoxRefsContext, ComboBoxStateContext } from './helpers.js';
import { ComboBoxEndIcon } from './ComboBoxEndIcon.js';
import { ComboBoxInput } from './ComboBoxInput.js';
import { ComboBoxInputContainer } from './ComboBoxInputContainer.js';
import { ComboBoxMenu } from './ComboBoxMenu.js';
import { ComboBoxMenuItem } from './ComboBoxMenuItem.js';
let isMultipleEnabled = (variable, multiple) =>
multiple && (Array.isArray(variable) || null == variable);
let isSingleOnChange = (onChange, multiple) => !multiple;
let getOptionId = (option, idPrefix) =>
option.id ?? `${idPrefix}-option-${option.label.replace(/\s/g, '-')}`;
export const ComboBox = React.forwardRef((props, forwardedRef) => {
let idPrefix = useId();
let defaultFilterFunction = React.useCallback(
(options, inputValue) =>
options.filter((option) =>
option.label.toLowerCase().includes(inputValue.toLowerCase()),
),
[],
);
let {
options,
value: valueProp,
onChange,
filterFunction = defaultFilterFunction,
inputProps,
endIconProps,
dropdownMenuProps: { middleware, ...dropdownMenuProps } = {},
emptyStateMessage = 'No options found',
itemRenderer,
enableVirtualization = false,
multiple = false,
onShow: onShowProp,
onHide: onHideProp,
id = inputProps?.id ? `iui-${inputProps.id}-cb` : idPrefix,
defaultValue,
clearFilterOnOptionToggle = true,
...rest
} = props;
let inputRef = React.useRef(null);
let menuRef = React.useRef(null);
let onChangeProp = useLatestRef(onChange);
let optionsRef = useLatestRef(options);
let filterFunctionRef = useLatestRef(filterFunction);
let optionsExtraInfo = React.useMemo(() => {
let newOptionsExtraInfo = {};
options.forEach((option, index) => {
newOptionsExtraInfo[getOptionId(option, id)] = {
__originalIndex: index,
};
});
return newOptionsExtraInfo;
}, [id, options]);
let getSelectedIndexes = React.useCallback(
(value) => {
if (void 0 === value) return;
if (!isMultipleEnabled(value, multiple))
return options.findIndex((option) => option.value === value);
{
let indexArray = [];
value?.forEach((value) => {
let indexToAdd = options.findIndex(
(option) => option.value === value,
);
if (indexToAdd > -1) indexArray.push(indexToAdd);
});
return indexArray;
}
},
[multiple, options],
);
let [selectedIndexes, setSelectedIndexes] = useControlledState(
getSelectedIndexes(defaultValue) ?? (multiple ? [] : -1),
getSelectedIndexes(valueProp),
);
let previousValue = React.useRef(valueProp);
useLayoutEffect(() => {
if (valueProp !== previousValue.current) {
previousValue.current = valueProp;
if (void 0 === valueProp)
isMultipleEnabled(selectedIndexes, multiple)
? setSelectedIndexes([])
: setSelectedIndexes(-1);
}
}, [multiple, selectedIndexes, setSelectedIndexes, valueProp]);
let [isOpen, setIsOpen] = React.useState(false);
let [focusedIndex, setFocusedIndex] = React.useState(-1);
let onShowRef = useLatestRef(onShowProp);
let onHideRef = useLatestRef(onHideProp);
let show = React.useCallback(() => {
setIsOpen(true);
onShowRef.current?.();
}, [onShowRef]);
let hide = React.useCallback(() => {
setIsOpen(false);
onHideRef.current?.();
}, [onHideRef]);
useLayoutEffect(() => {
if (isOpen) {
inputRef.current?.focus();
if (!isMultipleEnabled(selectedIndexes, multiple))
setFocusedIndex(selectedIndexes ?? -1);
} else {
setFocusedIndex(-1);
isMultipleEnabled(selectedIndexes, multiple)
? setInputValue('')
: setInputValue(
selectedIndexes >= 0
? optionsRef.current[selectedIndexes]?.label ?? ''
: '',
);
setIsInputDirty(false);
}
}, [isOpen, multiple, optionsRef, selectedIndexes]);
let previousOptions = React.useRef(options);
React.useEffect(() => {
if (options !== previousOptions.current) {
previousOptions.current = options;
onOptionsChange();
}
function onOptionsChange() {
isMultipleEnabled(selectedIndexes, multiple)
? setFocusedIndex(-1)
: setFocusedIndex(selectedIndexes);
if (!isMultipleEnabled(selectedIndexes, multiple) && !isOpen)
setInputValue(
selectedIndexes >= 0 ? options[selectedIndexes]?.label : '',
);
}
}, [options, isOpen, multiple, selectedIndexes]);
let [inputValue, setInputValue] = React.useState(
inputProps?.value?.toString() ?? '',
);
let [isInputDirty, setIsInputDirty] = React.useState(false);
let filteredOptions = React.useMemo(() => {
if (!isInputDirty) return options;
return filterFunctionRef.current?.(options, inputValue);
}, [filterFunctionRef, inputValue, options, isInputDirty]);
let [liveRegionSelection, setLiveRegionSelection] = React.useState('');
let handleOnInput = React.useCallback(
(event) => {
let { value } = event.currentTarget;
setInputValue(value);
show();
setIsInputDirty(true);
if (-1 != focusedIndex) setFocusedIndex(-1);
inputProps?.onChange?.(event);
},
[focusedIndex, inputProps, show],
);
let isMenuItemSelected = React.useCallback(
(index) => {
if (isMultipleEnabled(selectedIndexes, multiple))
return selectedIndexes.includes(index);
return selectedIndexes === index;
},
[multiple, selectedIndexes],
);
let selectedChangeHandler = React.useCallback(
(__originalIndex, action) => {
if (!isMultipleEnabled(selectedIndexes, multiple)) return;
if ('added' === action) return [...selectedIndexes, __originalIndex];
return selectedIndexes?.filter((index) => index !== __originalIndex);
},
[selectedIndexes, multiple],
);
let onChangeHandler = React.useCallback(
(__originalIndex, actionType, newSelectedIndexes) => {
if (isSingleOnChange(onChangeProp.current, multiple))
onChangeProp.current?.(optionsRef.current[__originalIndex]?.value);
else
actionType &&
newSelectedIndexes &&
onChangeProp.current?.(
newSelectedIndexes?.map(
(index) => optionsRef.current[index]?.value,
),
{
value: optionsRef.current[__originalIndex]?.value,
type: actionType,
},
);
},
[multiple, onChangeProp, optionsRef],
);
let handleOptionSelection = React.useCallback(
(__originalIndex) => {
inputRef.current?.focus({
preventScroll: true,
});
if (optionsRef.current[__originalIndex]?.disabled) return;
if (multiple) {
let actionType = isMenuItemSelected(__originalIndex)
? 'removed'
: 'added';
let newSelectedIndexes = selectedChangeHandler(
__originalIndex,
actionType,
);
if (null == newSelectedIndexes) return;
setSelectedIndexes(newSelectedIndexes);
onChangeHandler(__originalIndex, actionType, newSelectedIndexes);
setLiveRegionSelection(
newSelectedIndexes
.map((item) => optionsRef.current[item]?.label)
.filter(Boolean)
.join(', '),
);
if (clearFilterOnOptionToggle) {
setInputValue('');
setIsInputDirty(false);
}
} else {
setSelectedIndexes(__originalIndex);
hide();
onChangeHandler(__originalIndex);
}
},
[
optionsRef,
multiple,
isMenuItemSelected,
selectedChangeHandler,
setSelectedIndexes,
onChangeHandler,
clearFilterOnOptionToggle,
hide,
],
);
let getMenuItem = React.useCallback(
(option, filteredIndex) => {
let optionId = getOptionId(option, id);
let { __originalIndex } = optionsExtraInfo[optionId];
let { icon, startIcon: startIconProp, label, ...restOptions } = option;
let startIcon = startIconProp ?? icon;
let customItem = itemRenderer
? itemRenderer(option, {
isFocused: focusedIndex === __originalIndex,
isSelected: isMenuItemSelected(__originalIndex),
index: __originalIndex,
id: optionId,
})
: null;
return customItem
? React.cloneElement(customItem, {
onClick: (e) => {
handleOptionSelection(__originalIndex);
customItem.props.onClick?.(e);
},
focused: focusedIndex === __originalIndex,
'data-iui-index': __originalIndex,
'data-iui-filtered-index': filteredIndex,
ref: mergeRefs(customItem.props.ref, (el) => {
if (!enableVirtualization && focusedIndex === __originalIndex)
el?.scrollIntoView({
block: 'nearest',
});
}),
})
: React.createElement(
ComboBoxMenuItem,
{
key: optionId,
id: optionId,
startIcon: startIcon,
...restOptions,
isSelected: isMenuItemSelected(__originalIndex),
onClick: () => {
handleOptionSelection(__originalIndex);
},
index: __originalIndex,
'data-iui-filtered-index': filteredIndex,
},
label,
);
},
[
enableVirtualization,
focusedIndex,
id,
isMenuItemSelected,
itemRenderer,
handleOptionSelection,
optionsExtraInfo,
],
);
let emptyContent = React.useMemo(
() =>
React.createElement(
React.Fragment,
null,
React.isValidElement(emptyStateMessage)
? emptyStateMessage
: React.createElement(
MenuExtraContent,
null,
React.createElement(
Text,
{
isMuted: true,
},
emptyStateMessage,
),
),
),
[emptyStateMessage],
);
let popover = usePopover({
visible: isOpen,
onVisibleChange: (open) => (open ? show() : hide()),
matchWidth: true,
middleware: {
size: {
maxHeight: 'var(--iui-menu-max-height)',
},
...middleware,
},
closeOnOutsideClick: true,
interactions: {
click: false,
focus: true,
},
});
return React.createElement(
ComboBoxRefsContext.Provider,
{
value: React.useMemo(
() => ({
inputRef,
menuRef,
optionsExtraInfo,
}),
[optionsExtraInfo],
),
},
React.createElement(
ComboBoxStateContext.Provider,
{
value: React.useMemo(
() => ({
id,
isOpen,
focusedIndex,
setFocusedIndex,
onClickHandler: handleOptionSelection,
enableVirtualization,
filteredOptions,
getMenuItem,
multiple,
popover,
show,
hide,
}),
[
enableVirtualization,
filteredOptions,
focusedIndex,
getMenuItem,
handleOptionSelection,
hide,
id,
isOpen,
multiple,
popover,
show,
],
),
},
React.createElement(
ComboBoxInputContainer,
{
ref: forwardedRef,
disabled: inputProps?.disabled,
...rest,
},
React.createElement(ComboBoxInput, {
value: inputValue,
disabled: inputProps?.disabled,
...inputProps,
onChange: handleOnInput,
'aria-describedby': [
multiple ? `${id}-selected-live` : void 0,
inputProps?.['aria-describedby'],
]
.filter(Boolean)
.join(' '),
selectTags: isMultipleEnabled(selectedIndexes, multiple)
? selectedIndexes
?.map((index) => {
let option = options[index];
let optionId = getOptionId(option, id);
let { __originalIndex } = optionsExtraInfo[optionId];
return React.createElement(SelectTag, {
key: option.label,
label: option.label,
onRemove: inputProps?.disabled
? void 0
: () => {
handleOptionSelection(__originalIndex);
hide();
},
});
})
.filter(Boolean)
: void 0,
}),
React.createElement(ComboBoxEndIcon, {
...endIconProps,
disabled: inputProps?.disabled,
isOpen: isOpen,
}),
multiple
? React.createElement(AutoclearingHiddenLiveRegion, {
text: liveRegionSelection,
id: `${id}-selected-live`,
})
: null,
),
React.createElement(
ComboBoxMenu,
{
as: 'div',
...dropdownMenuProps,
},
filteredOptions.length > 0 && !enableVirtualization
? filteredOptions.map(getMenuItem)
: emptyContent,
),
),
);
});
if ('development' === process.env.NODE_ENV) ComboBox.displayName = 'ComboBox';