@chayns-components/core
Version:
A set of beautiful React components for developing your own applications with chayns.
403 lines (398 loc) • 16.5 kB
JavaScript
import { useFunctions, useValues } from 'chayns-api';
import React, { forwardRef, Fragment, useCallback, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { useIsTouch } from '../../utils/environment';
import { AreaContext } from '../area-provider/AreaContextProvider';
import Icon from '../icon/Icon';
import ComboBoxItem from './combobox-item/ComboBoxItem';
import { StyledComboBox, StyledComboBoxBody, StyledComboBoxClearIconWrapper, StyledComboBoxHeader, StyledComboBoxIconWrapper, StyledComboBoxInput, StyledComboBoxPlaceholder, StyledComboBoxPlaceholderImage, StyledComboBoxPlaceholderText, StyledComboBoxPrefix, StyledComboBoxPrefixAndPlaceholderWrapper, StyledComboBoxTopic } from './ComboBox.styles';
import DropdownBodyWrapper from '../dropdown-body-wrapper/DropdownBodyWrapper';
import { DropdownDirection } from '../../types/dropdown';
import { useElementSize } from '../../hooks/element';
import { ComboBoxSize } from './ComboBox.types';
import { getComboBoxWidthResult } from './ComboBox.utils';
import { useKeyboardFocusHighlighting } from '../../hooks/useKeyboardFocusHighlighting';
const ComboBox = /*#__PURE__*/forwardRef(({
bodyWidth,
direction = DropdownDirection.RIGHT,
isDisabled = false,
lists,
maxHeight = 280,
onSelect,
placeholder,
prefix,
container,
shouldCaptureEvents,
selectedItem,
onHide,
onShow,
shouldShowBigImage,
shouldShowClearIcon,
shouldShowRoundImage,
onInputFocus,
prefixMinWidth,
size = ComboBoxSize.NORMAL,
shouldUseFullWidth = false,
onInputChange,
shouldUseCurrentItemWidth = false,
onInputBlur,
shouldShowTransparentBackground = false,
inputValue,
shouldDropDownUseMaxItemWidth = false,
shouldEnableKeyboardHighlighting
}, ref) => {
const [internalSelectedItem, setInternalSelectedItem] = useState();
const [isAnimating, setIsAnimating] = useState(false);
const [minWidth, setMinWidth] = useState(undefined);
const [bodyMinWidth, setBodyMinWidth] = useState(0);
const [focusedIndex, setFocusedIndex] = useState(null);
const [availableMaxHeight, setAvailableMaxHeight] = useState(undefined);
const isInputFocused = useRef(false);
const styledComboBoxElementRef = useRef(null);
const comboBoxHeaderRef = useRef(null);
const comboBoxInputRef = useRef(null);
const contentRef = useRef(null);
const parentSize = useElementSize(styledComboBoxElementRef, {
shouldUseParentElement: true
});
const functions = useFunctions();
const values = useValues();
const isTouch = useIsTouch();
const areaProvider = useContext(AreaContext);
useEffect(() => {
if (!parentSize) {
return;
}
const {
minWidth: calculatedMinWidth,
bodyMinWidth: calculatedBodyMinWidth
} = getComboBoxWidthResult({
functions,
internalSelectedItem,
lists,
parentWidth: parentSize.width,
placeholder,
prefix,
prefixMinWidth,
selectedItem,
shouldDropDownUseMaxItemWidth,
shouldShowBigImage,
shouldShowClearIcon,
shouldUseCurrentItemWidth,
shouldUseFullWidth,
values
});
setMinWidth(calculatedMinWidth);
setBodyMinWidth(calculatedBodyMinWidth);
}, [functions, internalSelectedItem, lists, parentSize, placeholder, prefix, prefixMinWidth, selectedItem, shouldDropDownUseMaxItemWidth, shouldShowBigImage, shouldShowClearIcon, shouldUseCurrentItemWidth, shouldUseFullWidth, values]);
const shouldChangeColor = useMemo(() => areaProvider.shouldChangeColor ?? false, [areaProvider.shouldChangeColor]);
const shouldShowKeyboardHighlighting = useKeyboardFocusHighlighting(shouldEnableKeyboardHighlighting && !isDisabled);
const shouldDisableActions = useMemo(() => {
if (!selectedItem) {
return false;
}
const combinedLists = lists.flatMap(list => list.list);
return combinedLists.length === 1 && combinedLists.some(item => item.value === selectedItem.value);
}, [lists, selectedItem]);
// Limits the configured maxHeight by the height that is actually available inside the
// container (reported by the DropdownBodyWrapper). This prevents the dropdown from being cut
// off when it is opened to the top or bottom and there is not enough space.
const effectiveMaxHeight = useMemo(() => {
if (typeof availableMaxHeight === 'number' && availableMaxHeight > 0) {
return Math.min(maxHeight, availableMaxHeight);
}
return maxHeight;
}, [availableMaxHeight, maxHeight]);
const contentHeight = useMemo(() => {
const flatItems = lists.flatMap(list => list.list);
let height = flatItems.reduce((value, item) => {
const isBigItem = shouldShowBigImage || typeof item.subtext === 'string' && item.subtext.trim() !== '';
return value + (isBigItem ? 56 : 38);
}, 0);
if (lists.length > 1) {
height += lists.length * 38;
}
if (effectiveMaxHeight < height) {
height = effectiveMaxHeight;
}
return height;
}, [effectiveMaxHeight, lists, shouldShowBigImage]);
const handleInputFocus = useCallback(event => {
isInputFocused.current = true;
onInputFocus?.(event);
}, [onInputFocus]);
const handleInputBlur = useCallback(event => {
isInputFocused.current = false;
onInputBlur?.(event);
}, [onInputBlur]);
const handleOpen = useCallback(() => {
if (typeof onShow === 'function') {
onShow();
}
setIsAnimating(true);
}, [onShow]);
const handleClose = useCallback(() => {
if (typeof onHide === 'function') {
onHide();
}
setIsAnimating(false);
}, [onHide]);
const handleContainerBlur = useCallback(event => {
const nextFocusedElement = event.relatedTarget;
const currentContainer = event.currentTarget;
if (!nextFocusedElement || !currentContainer.contains(nextFocusedElement) && !contentRef.current?.contains(nextFocusedElement)) {
handleClose();
}
}, [handleClose]);
const restoreTriggerFocus = useCallback(() => {
// Delay is needed so focus happens after dropdown close/render cycle.
requestAnimationFrame(() => {
if (typeof inputValue === 'string') {
comboBoxInputRef.current?.focus();
return;
}
comboBoxHeaderRef.current?.focus();
});
}, [inputValue]);
/**
* This function sets the selected item
*/
const handleSetSelectedItem = useCallback(itemToSelect => {
if (typeof onSelect === 'function') {
const onSelectResult = onSelect(itemToSelect);
if (onSelectResult === false) {
return;
}
if (onSelectResult instanceof Promise) {
void onSelectResult.then(shouldPreventSelection => {
if (shouldPreventSelection) return;
setInternalSelectedItem(itemToSelect);
handleClose();
restoreTriggerFocus();
});
return;
}
}
setInternalSelectedItem(itemToSelect);
handleClose();
restoreTriggerFocus();
}, [handleClose, onSelect, restoreTriggerFocus]);
const handleClear = useCallback(event => {
event.preventDefault();
event.stopPropagation();
handleSetSelectedItem(undefined);
}, [handleSetSelectedItem]);
useEffect(() => {
const handleKeyDown = e => {
if (!isAnimating) return;
if (e.key === 'Escape') {
e.preventDefault();
handleClose();
restoreTriggerFocus();
return;
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const children = contentRef.current?.children;
if (!children || children.length === 0) return;
const stepDirection = e.key === 'ArrowUp' ? -1 : 1;
let newIndex = focusedIndex ?? -1;
let attempts = 0;
do {
newIndex = (newIndex + stepDirection + children.length) % children.length;
const newElement = children[newIndex];
let shouldSkip = false;
if (newElement.id.startsWith('combobox-group--') || newElement.id.endsWith('--disabled-item')) {
shouldSkip = true;
}
if (!shouldSkip) break;
attempts++;
} while (attempts < children.length);
if (focusedIndex !== null) {
const prevElement = children[focusedIndex];
prevElement.tabIndex = -1;
}
setFocusedIndex(newIndex);
const focusedElement = children[newIndex];
focusedElement.tabIndex = 0;
focusedElement.focus();
} else if ((e.key === 'Enter' || e.key === ' ') && focusedIndex !== null) {
e.preventDefault();
const element = contentRef.current?.children[focusedIndex];
if (!element) return;
const {
id
} = element;
let newSelectedItem;
lists.some(list => {
newSelectedItem = list.list.find(({
value
}) => String(value) === id.replace('combobox-item__', ''));
return !!newSelectedItem;
});
if (newSelectedItem) {
handleSetSelectedItem(newSelectedItem);
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [focusedIndex, handleClose, handleSetSelectedItem, isAnimating, lists, restoreTriggerFocus]);
/**
* This function sets the external selected item
*/
useEffect(() => {
setIsAnimating(false);
setInternalSelectedItem(selectedItem);
}, [selectedItem]);
const placeholderImageUrl = useMemo(() => {
if (selectedItem) {
return selectedItem.imageUrl;
}
if (internalSelectedItem) {
return internalSelectedItem.imageUrl;
}
return undefined;
}, [internalSelectedItem, selectedItem]);
const placeholderIcon = useMemo(() => {
if (selectedItem) {
return selectedItem.icons;
}
if (internalSelectedItem) {
return internalSelectedItem.icons;
}
return undefined;
}, [internalSelectedItem, selectedItem]);
/**
* This function resets the placeholder
*/
const placeholderText = useMemo(() => {
let text = placeholder;
if (selectedItem) {
text = selectedItem.text;
} else if (internalSelectedItem) {
text = internalSelectedItem.text;
}
return text;
}, [internalSelectedItem, placeholder, selectedItem]);
const shouldShowRoundPlaceholderImage = useMemo(() => {
const selectedItemList = lists.find(list => list.list.some(({
value
}) => value === (selectedItem?.value ?? internalSelectedItem?.value)));
return selectedItemList?.shouldShowRoundImage ?? shouldShowRoundImage;
}, [internalSelectedItem?.value, lists, selectedItem?.value, shouldShowRoundImage]);
/**
* This function opens the content of the combobox
*/
const handleHeaderClick = useCallback(() => {
if (!isDisabled && !isInputFocused.current) {
if (isAnimating) {
handleClose();
} else {
handleOpen();
}
}
}, [handleClose, handleOpen, isAnimating, isDisabled]);
const handleHeaderKeyDown = useCallback(event => {
if (isDisabled || typeof inputValue === 'string') {
return;
}
if (event.key === 'Escape' && isAnimating) {
event.preventDefault();
handleClose();
restoreTriggerFocus();
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handleHeaderClick();
}
}, [handleClose, handleHeaderClick, inputValue, isAnimating, isDisabled, restoreTriggerFocus]);
useImperativeHandle(ref, () => ({
hide: handleClose,
show: handleOpen
}), [handleClose, handleOpen]);
const comboBoxGroups = useMemo(() => lists.map(list => /*#__PURE__*/React.createElement(Fragment, {
key: list.groupName ?? 'default-group'
}, list.groupName && lists.length > 1 && /*#__PURE__*/React.createElement(StyledComboBoxTopic, {
id: `combobox-group--${list.groupName}`
}, list.groupName), list.list.map(item => /*#__PURE__*/React.createElement(ComboBoxItem, {
key: `item-${item.text}`,
item: item,
isSelected: selectedItem ? item.value === selectedItem.value : false,
onSelect: handleSetSelectedItem,
shouldShowBigImage: shouldShowBigImage,
shouldShowRoundImage: list.shouldShowRoundImage ?? shouldShowRoundImage
})))), [handleSetSelectedItem, lists, selectedItem, shouldShowBigImage, shouldShowRoundImage]);
return useMemo(() => /*#__PURE__*/React.createElement(StyledComboBox, {
ref: styledComboBoxElementRef,
$minWidth: minWidth,
$shouldUseFullWidth: shouldUseFullWidth,
onBlur: handleContainerBlur
}, /*#__PURE__*/React.createElement(StyledComboBoxHeader, {
ref: comboBoxHeaderRef,
$direction: direction,
onClick: handleHeaderClick,
$isOpen: isAnimating,
$isTouch: isTouch,
$size: size,
$shouldShowTransparentBackground: shouldShowTransparentBackground,
$isDisabled: isDisabled,
$shouldChangeColor: shouldChangeColor,
$shouldShowBigImage: shouldShowBigImage,
$shouldShowKeyboardHighlighting: shouldShowKeyboardHighlighting,
onKeyDown: handleHeaderKeyDown,
tabIndex: !isDisabled && typeof inputValue !== 'string' ? 0 : undefined,
role: !isDisabled && typeof inputValue !== 'string' ? 'button' : undefined,
"aria-expanded": !isDisabled && typeof inputValue !== 'string' ? isAnimating : undefined
}, /*#__PURE__*/React.createElement(StyledComboBoxPrefixAndPlaceholderWrapper, null, prefix && /*#__PURE__*/React.createElement(StyledComboBoxPrefix, {
$prefixMinWidth: prefixMinWidth
}, prefix), /*#__PURE__*/React.createElement(StyledComboBoxPlaceholder, {
$shouldReduceOpacity: !selectedItem && !internalSelectedItem
}, placeholderImageUrl && /*#__PURE__*/React.createElement(StyledComboBoxPlaceholderImage, {
src: placeholderImageUrl,
$shouldShowBigImage: shouldShowBigImage,
$shouldShowRoundImage: shouldShowRoundPlaceholderImage
}), placeholderIcon && /*#__PURE__*/React.createElement(Icon, {
icons: placeholderIcon
}), typeof inputValue === 'string' ? /*#__PURE__*/React.createElement(StyledComboBoxInput, {
ref: comboBoxInputRef,
disabled: isDisabled,
value: inputValue,
onChange: onInputChange,
onBlur: handleInputBlur,
onFocus: handleInputFocus,
placeholder: placeholderText
}) : /*#__PURE__*/React.createElement(StyledComboBoxPlaceholderText, null, placeholderText), internalSelectedItem && internalSelectedItem.suffixElement && internalSelectedItem.suffixElement)), shouldShowClearIcon && internalSelectedItem && /*#__PURE__*/React.createElement(StyledComboBoxClearIconWrapper, {
$isDisabled: isDisabled,
onClick: handleClear
}, /*#__PURE__*/React.createElement(Icon, {
icons: ['fa fa-times']
})), !shouldDisableActions && /*#__PURE__*/React.createElement(StyledComboBoxIconWrapper, {
$isDisabled: isDisabled,
$size: size,
$shouldShowBorderLeft: shouldShowClearIcon === true && internalSelectedItem !== undefined
}, /*#__PURE__*/React.createElement(Icon, {
icons: ['fa fa-chevron-down'],
isDisabled: isDisabled
}))), styledComboBoxElementRef.current && /*#__PURE__*/React.createElement(DropdownBodyWrapper, {
anchorElement: styledComboBoxElementRef.current,
bodyWidth: bodyWidth,
contentHeight: contentHeight,
shouldCaptureEvents: shouldCaptureEvents,
onAvailableMaxHeightChange: setAvailableMaxHeight,
onClose: handleClose,
direction: direction,
container: container,
shouldShowDropdown: isAnimating,
minBodyWidth: bodyWidth ?? bodyMinWidth
}, /*#__PURE__*/React.createElement(StyledComboBoxBody, {
$maxHeight: effectiveMaxHeight,
$minWidth: bodyWidth ?? bodyMinWidth,
className: "chayns-scrollbar",
ref: contentRef,
tabIndex: 0
}, comboBoxGroups))), [bodyMinWidth, bodyWidth, comboBoxGroups, container, contentHeight, direction, handleClear, handleClose, handleHeaderClick, handleHeaderKeyDown, handleInputBlur, handleInputFocus, inputValue, internalSelectedItem, isAnimating, isDisabled, isTouch, effectiveMaxHeight, minWidth, onInputChange, placeholderIcon, placeholderImageUrl, placeholderText, prefix, prefixMinWidth, selectedItem, shouldChangeColor, shouldDisableActions, shouldCaptureEvents, shouldShowKeyboardHighlighting, shouldShowBigImage, shouldShowClearIcon, shouldShowRoundPlaceholderImage, shouldShowTransparentBackground, shouldUseFullWidth, size]);
});
ComboBox.displayName = 'ComboBox';
export default ComboBox;
//# sourceMappingURL=ComboBox.js.map