import React, { useEffect, useRef, useState } from 'react'; import { isEqual } from 'lodash'; import { classes, cloneChildren, noop, useControlledState } from '../../utils'; import { autoUpdate, flip, FloatingFocusManager, FloatingList, hide, offset, shift, size, useClick, useDismiss, useFloating, useFocus, useInteractions, useListItem, useListNavigation, useTransitionStyles, type UseTransitionStylesProps, } from '@floating-ui/react'; import defaultComponents from './components'; import { Portal } from '../portal'; import type { SelectProps, SelectComponents, SelectItemProps, DefaultMetaType, InternalItemProps } from './models'; import { HelperText } from '../helperText'; import { ErrorNotification, WarningNotification } from '../input/Input'; const transitionOpts: UseTransitionStylesProps = { duration: 150, initial: ({ side }) => ({ opacity: 0, transform: side === 'top' || side === 'bottom' ? 'scaleY(0.95)' : 'scaleX(0.95)', }), }; const Root = ({ id, value: propValue, defaultValue, disabled = false, searchable = true, children, placeholder = 'Select...', ariaLabel, readLabel, isValid = true, sizing = 'md', helperText, errorMessage, open: propOpen, defaultOpen, meta, isLoading = false, components: propComponents, onChange = noop, onBlur, onChangeOpen, filterOption, placement = 'bottom-start', offset: propOffset, }: SelectProps) => { const [open, setOpen] = useControlledState({ value: propOpen, defaultValue: defaultOpen, onChange: onChangeOpen, }); const [value = null, setValue] = useControlledState({ value: propValue, defaultValue, onChange, }); const [activeIndex, setActiveIndex] = useState(null); const [inputValue, setInputValue] = useState(''); const [isWarning, setIsWarning] = useState(false); const warningMessage = isWarning ? `Selected value is not present in the options list (deprecated). Please select a valid value.` : undefined; const menuBodyRef = useRef(null); const searchInputRef = useRef(null); const listRef = useRef<(HTMLLIElement | null)[]>([]); const isItemValid = isValid && !errorMessage; const { refs, floatingStyles, context, middlewareData, elements, update } = useFloating({ open, whileElementsMounted: autoUpdate, placement, strategy: 'absolute', middleware: [ offset( typeof propOffset !== 'undefined' ? propOffset : ({ rects }) => ({ mainAxis: -rects.reference.height, }) ), hide(), flip({ fallbackAxisSideDirection: 'start', crossAxis: false, }), shift(), size({ apply({ rects, elements, availableHeight }) { Object.assign(elements.floating.style, { width: `${rects.reference.width}px`, }); if (menuBodyRef.current) { menuBodyRef.current.style.setProperty('max-height', `${availableHeight}px`); } }, }), ], onOpenChange: setOpen, }); useEffect(() => { if (open && elements.reference && elements.floating) { return autoUpdate(elements.reference, elements.floating, update); } }, [value]); const listNav = useListNavigation(context, { activeIndex, listRef, onNavigate: setActiveIndex, }); const { isMounted, styles: transitionStyles } = useTransitionStyles(context, transitionOpts); const { getFloatingProps, getReferenceProps } = useInteractions([ useFocus(context), useDismiss(context), useClick(context), listNav, ]); const isVisible: InternalItemProps['isVisible'] = (item, label) => { if (filterOption) { return filterOption(item, inputValue); } return label.toLowerCase().includes(inputValue.toLowerCase()); }; const handleSelect: InternalItemProps['onSelect'] = value => { setInputValue(''); setValue(value); setOpen(false); }; const handleDelete = () => { setInputValue(''); setValue(null); if (searchInputRef.current) { searchInputRef.current.focus(); } }; const isSelected: InternalItemProps['isSelected'] = itemValue => isEqual(itemValue, value); const isValueDeprecated = !React.Children.toArray(children).some( child => React.isValidElement(child) && isSelected(child.props.value) ); useEffect(() => { if (isValueDeprecated && value) { setIsWarning(true); } else { setIsWarning(false); } }, [value, isValueDeprecated]); const activeLabel: string | null = (() => { if (!value) { return null; } if (readLabel) { return readLabel(value); } const childrenArr = Array.isArray(children) ? children : [children]; const index = childrenArr.findIndex(child => isSelected(child.props.value)); if (index === -1) { return String(value); } return childrenArr[index].props.children; })(); if (!isValid && !errorMessage) { // eslint-disable-next-line no-console console.error( `You provided isValid as false. Please provide errorMessage for user to see the error description.` ); } const iconStyles = classes( { 'px-[10px]': sizing === 'md', 'px-[6px]': sizing === 'sm', }, { 'py-[10px]': sizing === 'md', 'py-[6px]': sizing === 'sm', } ); const components = { ...defaultComponents, ...propComponents, } as SelectComponents; return (
{isLoading ? ( Loading... ) : value ? ( {activeLabel} ) : ( {placeholder} )} {isLoading ? ( ) : ( )} {!isItemValid && (
)} {isWarning && warningMessage && (
)}
{isMounted && ( {value && ( {activeLabel} )} {!searchable && !value && ( {placeholder} )} {searchable && ( setInputValue(e.target.value)} /> )} {cloneChildren>(children, { activeIndex, Component: components.Option, readLabel, meta, isVisible, isSelected, onSelect: handleSelect, })} )}
{helperText && {helperText}}
); }; const Item = ({ Component, value, isVisible, isSelected, children, meta, activeIndex, readLabel, onSelect, isDisabled = false, }: SelectItemProps & InternalItemProps) => { const { ref, index } = useListItem(); const isActive = activeIndex === index; const selected = isSelected(value); const handleKeyDown: React.KeyboardEventHandler = e => { if (e.key === 'Enter') { e.preventDefault(); if (!selected) { onSelect(value); return; } onSelect(null); } }; if (!isVisible(value, children)) { return null; } return ( e.preventDefault()} onKeyDown={handleKeyDown} onClick={() => (selected ? onSelect(null) : onSelect(value))} > {readLabel ? readLabel(value) : children} ); }; export const Select = Object.assign(Root, { ...defaultComponents, Item: (props: SelectItemProps) => [0])} />, });