UNPKG

@chayns-components/core

Version:

A set of beautiful React components for developing your own applications with chayns.

465 lines (455 loc) • 18.6 kB
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