UNPKG

@chayns-components/core

Version:

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

403 lines (398 loc) • 16.5 kB
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