@chayns-components/core
Version:
A set of beautiful React components for developing your own applications with chayns.
465 lines (455 loc) • 18.6 kB
JavaScript
import { useDevice, useFunctions, useValues } from 'chayns-api';
import { AnimatePresence } from 'motion/react';
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ComboBoxDirection } from '../../types/comboBox';
import { calculateContentWidth, getMaxHeightInPixels } from '../../utils/calculate';
import { getIsTouch } from '../../utils/environment';
import { AreaContext } from '../area-provider/AreaContextProvider';
import Icon from '../icon/Icon';
import ComboBoxItem from './combobox-item/ComboBoxItem';
import { StyledComboBox, StyledComboBoxClearIconWrapper, StyledComboBoxHeader, StyledComboBoxIconWrapper, StyledComboBoxInput, StyledComboBoxPlaceholder, StyledComboBoxPlaceholderImage, StyledComboBoxPlaceholderText, StyledComboBoxPrefix, StyledComboBoxPrefixAndPlaceholderWrapper, StyledComboBoxTopic, StyledMotionComboBoxBody } from './ComboBox.styles';
const ComboBox = _ref => {
let {
bodyWidth,
direction = ComboBoxDirection.RIGHT,
isDisabled = false,
lists,
maxHeight = '280px',
onSelect,
placeholder,
prefix,
container,
selectedItem,
shouldShowBigImage,
shouldShowClearIcon,
shouldShowRoundImage,
onInputFocus,
shouldUseFullWidth = false,
onInputChange,
shouldUseCurrentItemWidth = false,
onInputBlur,
inputValue
} = _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 [overflowY, setOverflowY] = useState('hidden');
const [translateX, setTranslateX] = useState('0px');
const [translateY, setTranslateY] = useState('0px');
const [portal, setPortal] = useState();
const [internalCoordinates, setInternalCoordinates] = useState({
x: 0,
y: 0
});
const [newContainer, setNewContainer] = useState(container ?? null);
const [shouldUseTopAlignment, setShouldUseTopAlignment] = useState(false);
const isInputFocused = useRef(false);
const styledComboBoxElementRef = useRef(null);
const contentRef = useRef(null);
const functions = useFunctions();
const values = useValues();
const {
browser
} = useDevice();
const isTouch = getIsTouch();
const areaProvider = useContext(AreaContext);
const shouldChangeColor = useMemo(() => areaProvider.shouldChangeColor ?? false, [areaProvider.shouldChangeColor]);
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]);
const contentHeight = useMemo(() => {
const flatItems = lists.flatMap(list => list.list);
let result = flatItems.length * 36;
if (lists.length > 1) {
result += lists.length * 36;
}
// ToDo: Implement a better solution to also work with percentage values or other units
if (maxHeight.toString().includes('px')) {
const maxHeightValue = parseInt(maxHeight.toString().replace('px', ''), 10);
if (maxHeightValue < result) {
result = maxHeightValue;
}
}
return result;
}, [lists, maxHeight]);
useEffect(() => {
if (styledComboBoxElementRef.current && !container) {
const el = styledComboBoxElementRef.current;
const element = el.closest('.dialog-inner') || el.closest('.page-provider') || el.closest('body');
setNewContainer(element);
}
}, [container]);
useEffect(() => {
if (container instanceof Element) {
setNewContainer(container);
}
}, [container]);
const handleInputFocus = useCallback(event => {
isInputFocused.current = true;
onInputFocus?.(event);
}, [onInputFocus]);
const handleInputBlur = useCallback(event => {
isInputFocused.current = false;
onInputBlur?.(event);
}, [onInputBlur]);
const handleClick = useCallback(event => {
if (styledComboBoxElementRef.current && !styledComboBoxElementRef.current.contains(event.target) && contentRef.current && !contentRef.current.contains(event.target)) {
setIsAnimating(false);
}
}, [styledComboBoxElementRef]);
const handleOpen = useCallback(() => {
if (shouldDisableActions) {
return;
}
if (styledComboBoxElementRef.current && newContainer) {
const {
left: comboBoxLeft,
top: comboBoxTop,
height
} = styledComboBoxElementRef.current.getBoundingClientRect();
const {
left,
top,
height: containerHeight
} = newContainer.getBoundingClientRect();
const x = comboBoxLeft - left + newContainer.scrollLeft;
const y = comboBoxTop - top + newContainer.scrollTop;
let useTopAlignment = [ComboBoxDirection.TOP, ComboBoxDirection.TOP_LEFT, ComboBoxDirection.TOP_RIGHT].includes(direction);
const hasBottomAlignment = [ComboBoxDirection.BOTTOM, ComboBoxDirection.BOTTOM_LEFT, ComboBoxDirection.BOTTOM_RIGHT].includes(direction);
if (!hasBottomAlignment && y + height + contentHeight > containerHeight) {
useTopAlignment = true;
setShouldUseTopAlignment(true);
} else {
setShouldUseTopAlignment(false);
}
setInternalCoordinates({
x,
y: useTopAlignment ? y : y + height
});
setIsAnimating(true);
}
}, [shouldDisableActions, newContainer, contentHeight, direction]);
const handleClose = useCallback(() => {
setIsAnimating(false);
}, []);
/**
* This function adds an event listener to the document to close the combobox when the user clicks outside of it
*/
useEffect(() => {
document.addEventListener('click', handleClick);
return () => {
document.removeEventListener('click', handleClick);
};
}, [handleClick, styledComboBoxElementRef]);
/**
* 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);
setIsAnimating(false);
});
return;
}
}
setInternalSelectedItem(itemToSelect);
setIsAnimating(false);
}, [onSelect]);
const handleClear = useCallback(event => {
event.preventDefault();
event.stopPropagation();
handleSetSelectedItem(undefined);
}, [handleSetSelectedItem]);
useEffect(() => {
const currentContent = contentRef.current;
if (portal && isAnimating && currentContent) {
const scrollHeight = currentContent.scrollHeight ?? 0;
const maxHeightInPixels = getMaxHeightInPixels(maxHeight, styledComboBoxElementRef.current ?? document.body);
setOverflowY(scrollHeight > maxHeightInPixels ? 'scroll' : 'hidden');
}
}, [isAnimating, maxHeight, portal]);
useEffect(() => {
const handleKeyDown = e => {
if (!isAnimating) {
return;
}
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
const children = contentRef.current?.children;
if (children && children.length > 0) {
const newIndex = focusedIndex !== null ? (focusedIndex + (e.key === 'ArrowUp' ? -1 : 1) + children.length) % children.length : 0;
if (focusedIndex !== null) {
const prevElement = children[focusedIndex];
prevElement.tabIndex = -1;
}
setFocusedIndex(newIndex);
const newElement = children[newIndex];
newElement.tabIndex = 0;
newElement.focus();
}
} else if (e.key === 'Enter' && focusedIndex !== null) {
const element = contentRef.current?.children[focusedIndex];
if (!element) {
return;
}
const {
id
} = element;
let newSelectedItem;
lists.some(list => {
newSelectedItem = list.list.find(_ref2 => {
let {
value
} = _ref2;
return String(value) === id.replace('combobox-item__', '');
});
return !!newSelectedItem;
});
if (!newSelectedItem) {
return;
}
handleSetSelectedItem(newSelectedItem);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [focusedIndex, handleSetSelectedItem, isAnimating, lists]);
/**
* This function calculates the greatest width
*/
useEffect(() => {
const allItems = lists.flatMap(list => list.list);
const hasImage = [selectedItem, ...allItems].some(item => item?.imageUrl);
const hasIcon = [selectedItem, ...allItems].some(item => item?.icons);
const parentWidth = styledComboBoxElementRef.current?.parentElement?.getBoundingClientRect().width ?? 0;
const paddingWidth = 20 + 2 + 40 + 40; // padding + border + arrow icon + optional clear icon
const imageWidth = hasImage ? 32 : 0; // image width + gap if images present
const iconWidth = hasIcon ? 40 : 0; // icon width + gap if icons present
let prefixWidth = 0;
if (prefix) {
const prefixTextWidth = calculateContentWidth([{
text: prefix,
value: 'prefix'
}], functions, values) + 5;
prefixWidth = Math.max(prefixTextWidth, 32);
}
const baseWidth = calculateContentWidth([...allItems, {
text: placeholder,
value: 'placeholder'
}, ...(selectedItem ? [selectedItem] : [])], functions, values);
const calculatedWidth = baseWidth + paddingWidth + imageWidth + iconWidth + prefixWidth;
let tmpMinWidth = calculatedWidth;
let tmpBodyMinWidth = calculatedWidth;
// Full width settings
if (shouldUseFullWidth) {
tmpMinWidth = parentWidth;
tmpBodyMinWidth = parentWidth < calculatedWidth - 20 ? calculatedWidth - 20 : parentWidth;
}
// Current item width settings
else if (shouldUseCurrentItemWidth && internalSelectedItem) {
const itemWidth = calculateContentWidth([internalSelectedItem], functions, values) + paddingWidth + imageWidth + iconWidth + prefixWidth;
tmpMinWidth = itemWidth;
tmpBodyMinWidth = itemWidth < calculatedWidth - 20 ? calculatedWidth - 20 : itemWidth;
}
if (tmpMinWidth > parentWidth) {
tmpMinWidth = parentWidth;
}
if (tmpBodyMinWidth > parentWidth) {
tmpBodyMinWidth = parentWidth;
}
setMinWidth(tmpMinWidth);
setBodyMinWidth(shouldUseCurrentItemWidth ? tmpMinWidth : tmpBodyMinWidth);
}, [lists, placeholder, shouldUseFullWidth, shouldUseCurrentItemWidth, internalSelectedItem, prefix, selectedItem, functions, values]);
/**
* This function sets the external selected item
*/
useEffect(() => {
setIsAnimating(false);
setInternalSelectedItem(selectedItem);
}, [selectedItem]);
useEffect(() => {
if ([ComboBoxDirection.BOTTOM_LEFT, ComboBoxDirection.TOP_LEFT, ComboBoxDirection.LEFT].includes(direction) && typeof bodyWidth === 'number' && typeof minWidth === 'number') {
const difference = minWidth - bodyWidth;
setTranslateX(`${difference}px`);
} else {
setTranslateX('0px');
}
}, [bodyWidth, direction, minWidth]);
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(_ref3 => {
let {
value
} = _ref3;
return 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 comboBoxGroups = useMemo(() => lists.map(list => /*#__PURE__*/React.createElement("div", {
key: list.groupName ?? 'default-group'
}, list.groupName && lists.length > 1 && /*#__PURE__*/React.createElement(StyledComboBoxTopic, null, list.groupName), list.list.map(item =>
/*#__PURE__*/
// ToDo: Cleanup this - item should be given as a prop to avoid full spreading
React.createElement(ComboBoxItem, {
icons: item.icons,
id: item.value,
imageBackground: item.imageBackground,
imageUrl: item.imageUrl,
isDisabled: item.isDisabled,
isSelected: selectedItem ? item.value === selectedItem.value : false,
key: item.value,
onSelect: handleSetSelectedItem,
rightElement: item.rightElement,
shouldShowBigImage: shouldShowBigImage,
shouldShowRoundImage: list.shouldShowRoundImage ?? shouldShowRoundImage,
subtext: item.subtext,
suffixElement: item.suffixElement,
text: item.text,
value: item.value,
textStyles: item.textStyles
})))), [handleSetSelectedItem, lists, selectedItem, shouldShowBigImage, shouldShowRoundImage]);
const bodyStyles = useMemo(() => ({
left: internalCoordinates.x,
top: internalCoordinates.y
}), [internalCoordinates.x, internalCoordinates.y]);
useEffect(() => {
const useTopAlignment = shouldUseTopAlignment || [ComboBoxDirection.TOP, ComboBoxDirection.TOP_LEFT, ComboBoxDirection.TOP_RIGHT].includes(direction);
if (useTopAlignment) {
setTranslateY('-100%');
} else {
setTranslateY('0px');
}
}, [direction, shouldUseTopAlignment]);
useEffect(() => {
if (!newContainer) {
return;
}
setPortal(() => /*#__PURE__*/createPortal(/*#__PURE__*/React.createElement(AnimatePresence, {
initial: false
}, isAnimating && /*#__PURE__*/React.createElement(StyledMotionComboBoxBody, {
$browser: browser?.name,
animate: {
height: 'fit-content',
opacity: 1
},
$overflowY: overflowY,
$translateX: translateX,
$translateY: translateY,
initial: {
height: 0,
opacity: 0
},
exit: {
height: 0,
opacity: 0
},
$maxHeight: maxHeight,
$minWidth: bodyWidth ?? bodyMinWidth,
style: bodyStyles,
$direction: direction,
$shouldUseCurrentItemWidth: shouldUseCurrentItemWidth,
transition: {
duration: 0.2
},
tabIndex: 0,
ref: contentRef
}, comboBoxGroups)), newContainer));
}, [bodyWidth, bodyMinWidth, bodyStyles, browser?.name, comboBoxGroups, newContainer, direction, isAnimating, maxHeight, minWidth, overflowY, shouldUseCurrentItemWidth, translateX, translateY]);
return useMemo(() => /*#__PURE__*/React.createElement(StyledComboBox, {
ref: styledComboBoxElementRef,
$minWidth: minWidth,
$shouldUseFullWidth: shouldUseFullWidth,
$shouldUseCurrentItemWidth: shouldUseCurrentItemWidth
}, /*#__PURE__*/React.createElement(StyledComboBoxHeader, {
$direction: direction,
onClick: handleHeaderClick,
$isOpen: isAnimating,
$isTouch: isTouch,
$isDisabled: isDisabled,
$shouldChangeColor: shouldChangeColor,
$shouldShowBigImage: shouldShowBigImage
}, /*#__PURE__*/React.createElement(StyledComboBoxPrefixAndPlaceholderWrapper, null, prefix && /*#__PURE__*/React.createElement(StyledComboBoxPrefix, null, 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, {
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, {
onClick: handleClear
}, /*#__PURE__*/React.createElement(Icon, {
icons: ['fa fa-times']
})), !shouldDisableActions && /*#__PURE__*/React.createElement(StyledComboBoxIconWrapper, {
$shouldShowBorderLeft: shouldShowClearIcon === true && internalSelectedItem !== undefined
}, /*#__PURE__*/React.createElement(Icon, {
icons: ['fa fa-chevron-down']
}))), portal), [minWidth, shouldUseFullWidth, shouldUseCurrentItemWidth, direction, handleHeaderClick, isAnimating, isTouch, isDisabled, shouldChangeColor, shouldShowBigImage, prefix, selectedItem, internalSelectedItem, placeholderImageUrl, shouldShowRoundPlaceholderImage, placeholderIcon, inputValue, onInputChange, handleInputBlur, handleInputFocus, placeholderText, shouldShowClearIcon, handleClear, shouldDisableActions, portal]);
};
ComboBox.displayName = 'ComboBox';
export default ComboBox;
//# sourceMappingURL=ComboBox.js.map