/* eslint-disable react/require-default-props,react/forbid-prop-types */ import { ComponentDefaultTestId, getTestId } from "../../tests/test-ids-utils"; import cx from "classnames"; import { SIZES, SIZES_VALUES } from "../../constants"; import React, { forwardRef, useCallback, useMemo, useRef, useState, useEffect } from "react"; import Select, { InputProps, components, createFilter, ActionMeta } from "react-select"; import AsyncSelect from "react-select/async"; import BaseSelect from "react-select/base"; import { noop as NOOP } from "lodash-es"; import MenuComponent from "./components/menu/menu"; import DropdownIndicatorComponent from "./components/DropdownIndicator/DropdownIndicator"; import OptionComponent from "./components/option/option"; import SingleValueComponent from "./components/singleValue/singleValue"; import ClearIndicatorComponent from "./components/ClearIndicator/ClearIndicator"; import MultiValueContainer from "./components/MultiValueContainer/MultiValueContainer"; import { isClient } from "../../utils/ssr-utils"; import { ADD_AUTO_HEIGHT_COMPONENTS, defaultCustomStyles, DROPDOWN_CHIP_COLORS, DROPDOWN_ID, DROPDOWN_INPUT_ARIA_LABEL, DROPDOWN_MENU_ARIA_LABEL, DROPDOWN_MENU_ID, DROPDOWN_MENU_PLACEMENT, DROPDOWN_MENU_POSITION } from "./DropdownConstants"; import generateBaseStyles, { customTheme } from "./Dropdown.styles"; import Control from "./components/Control/Control"; import menuStyles from "./components/menu/menu.module.scss"; import styles from "./Dropdown.module.scss"; import { DropdownOption, DropdownState, CustomMenuProps, CustomOptionProps, CustomSingleValueProps, DropdownComponentProps } from "./Dropdown.types"; import { VibeComponent, withStaticProps } from "../../types"; const Dropdown: VibeComponent & { size?: typeof SIZES; sizes?: typeof SIZES; chipColors?: typeof DROPDOWN_CHIP_COLORS; menuPlacements?: typeof DROPDOWN_MENU_PLACEMENT; menuPositions?: typeof DROPDOWN_MENU_POSITION; createFilter?: typeof createFilter; } = forwardRef( ( { className, optionWrapperClassName, singleValueWrapperClassName, dropdownMenuWrapperClassName, placeholder = "", disabled = false, readOnly = false, withReadOnlyStyle, onMenuOpen = NOOP, onMenuClose = NOOP, onFocus = NOOP, onBlur = NOOP, onScroll = NOOP, onMenuScrollToBottom = NOOP, onChange: customOnChange = NOOP, searchable = true, captureMenuScroll = false, options = [], defaultValue, value: customValue, noOptionsMessage, openMenuOnFocus, openMenuOnClick, clearable = true, OptionRenderer, optionRenderer, ValueRenderer, valueRenderer, menuRenderer, menuPlacement = Dropdown.menuPlacements.BOTTOM, rtl, size = Dropdown.sizes.MEDIUM, asyncOptions, cacheOptions, defaultOptions, isVirtualized, menuPortalTarget, extraStyles = defaultCustomStyles, maxMenuHeight, menuIsOpen, tabIndex = "0", id = DROPDOWN_ID, menuId = DROPDOWN_MENU_ID, menuAriaLabel = DROPDOWN_MENU_ARIA_LABEL, inputAriaLabel = DROPDOWN_INPUT_ARIA_LABEL, autoFocus = false, multi = false, multiline = false, onOptionRemove: customOnOptionRemove, onOptionSelect, onClear, onInputChange = NOOP, closeMenuOnSelect = !multi, closeMenuOnScroll: customCloseMenuOnScroll = false, withMandatoryDefaultOptions = false, isOptionSelected, insideOverflowContainer = false, insideOverflowWithTransformContainer = false, tooltipContent = "", onKeyDown = NOOP, isLoading = false, loadingMessage, ariaLabel, tabSelectsValue = true, popupsContainerSelector, filterOption, menuPosition = Dropdown.menuPositions.ABSOLUTE, "data-testid": dataTestId, withGroupDivider = false, inputValue, blurInputOnSelect }: DropdownComponentProps, ref: React.ForwardedRef ) => { const controlRef = useRef(); const overrideMenuPortalTarget = menuPortalTarget || (popupsContainerSelector && document.querySelector(popupsContainerSelector)); const overrideDefaultValue = useMemo(() => { if (defaultValue) { return Array.isArray(defaultValue) ? (defaultValue as DropdownOption[]).map(df => ({ ...df, isMandatory: true })) : { ...(defaultValue as DropdownOption), isMandatory: true }; } return defaultValue; }, [defaultValue]); BaseSelect.prototype.renderLiveRegion = () => { return null; }; // SSR support const [WindowedMenuList, setWindowedMenuList] = useState(null); useEffect(() => { if (isClient()) { // Dynamically import the specific named export from react-windowed-select for SSR support import("react-windowed-select").then(module => { setWindowedMenuList(() => module.WindowedMenuList); }); } }, []); const [selected, setSelected] = useState(overrideDefaultValue || []); const [focusedOptionId, setFocusedOptionId] = useState(""); const finalOptionRenderer = optionRenderer || OptionRenderer; const finalValueRenderer = valueRenderer || ValueRenderer; const isControlled = !!customValue; const selectedOptions = customValue ?? selected; const selectedOptionsMap = useMemo(() => { if (Array.isArray(selectedOptions)) { return selectedOptions.reduce((acc, option) => ({ ...acc, [option.value]: option }), {}); } return {}; }, [selectedOptions]); const overrideAriaLabel = useMemo(() => { return ( ariaLabel || `${readOnly ? "Readonly " : ""} ${tooltipContent} ${ Array.isArray(selectedOptions) ? `Selected: ${selectedOptions.map(o => o.label).join(", ")}` : "Select" }` ); }, [ariaLabel, readOnly, selectedOptions, tooltipContent]); const value = multi ? selectedOptions : customValue; const inlineStyles = useMemo(() => { // We first want to get the default stylized groups (e.g. "container", "menu"). const baseStyles = generateBaseStyles({ size, rtl, insideOverflowContainer, controlRef, insideOverflowWithTransformContainer, withGroupDivider }); type BaseStyles = typeof baseStyles; // Then we want to run the consumer's root-level custom styles with our "base" override groups. const customStyles = extraStyles(baseStyles); // Lastly, we create a style groups object that makes sure we run each custom group with our basic overrides. const mergedStyles: any = Object.entries(customStyles).reduce((accumulator, [stylesGroup, stylesFn]) => { return { ...accumulator, [stylesGroup]: (defaultStyles: BaseStyles, state: DropdownState) => { const providedFn = baseStyles[stylesGroup as keyof BaseStyles]; const provided = providedFn ? providedFn(defaultStyles, state) : defaultStyles; return stylesFn(provided, state); } }; }, {} as BaseStyles); if (multi) { if (multiline) { ADD_AUTO_HEIGHT_COMPONENTS.forEach((component: string) => { const original = mergedStyles[component]; mergedStyles[component] = (provided: BaseStyles, state: DropdownState) => ({ ...original(provided, state), height: "auto" }); }); } const originalValueContainer = mergedStyles.valueContainer; mergedStyles.valueContainer = (provided: BaseStyles, state: DropdownState) => ({ ...originalValueContainer(provided, state), paddingLeft: 6 }); } return mergedStyles; }, [size, rtl, insideOverflowContainer, insideOverflowWithTransformContainer, extraStyles, multi, multiline]); const Menu = useCallback( (props: CustomMenuProps) => ( ), [dropdownMenuWrapperClassName, menuRenderer, menuId, menuAriaLabel, onScroll] ); const DropdownIndicator = useCallback( (props: React.HTMLAttributes & { size?: SIZES_VALUES }) => ( ), [size] ); const Option = useCallback( (props: CustomOptionProps) => ( ), [finalOptionRenderer, optionWrapperClassName, setFocusedOptionId] ); const Input = useCallback( (props: InputProps | any) => { const { focusedOptionId, menuIsOpen } = props.selectProps; const ariaActiveDescendant = focusedOptionId && menuIsOpen ? focusedOptionId : ""; return ( ); }, [menuId, readOnly] ); const SingleValue = useCallback( (props: CustomSingleValueProps) => ( ), [finalValueRenderer, readOnly, selectedOptions, singleValueWrapperClassName] ); const ClearIndicator = useCallback( (props: React.HTMLAttributes & { size?: SIZES_VALUES }) => ( ), [size] ); const onOptionRemove = useMemo(() => { return function (optionValue: number, e: React.MouseEvent | React.KeyboardEvent) { if (customOnOptionRemove) { customOnOptionRemove(selectedOptionsMap[optionValue]); } const newSelectedOptions = Array.isArray(selectedOptions) ? selectedOptions.filter(option => option.value !== optionValue) : selectedOptions; if (customOnChange) { customOnChange(newSelectedOptions, e); } setSelected(newSelectedOptions); }; }, [customOnChange, customOnOptionRemove, selectedOptions, selectedOptionsMap]); const customProps = useMemo( () => ({ selectedOptions, onSelectedDelete: onOptionRemove, isMultiline: multiline, insideOverflowContainer, insideOverflowWithTransformContainer, controlRef, tooltipContent, popupsContainerSelector, size }), [ selectedOptions, onOptionRemove, multiline, insideOverflowContainer, insideOverflowWithTransformContainer, tooltipContent, popupsContainerSelector, size ] ); const onChange = (option: DropdownOption | DropdownOption[], meta: ActionMeta) => { if (customOnChange) { customOnChange(option, meta); } switch (meta.action) { case "select-option": { const selectedOption = multi ? meta.option : option; if (onOptionSelect) { onOptionSelect(selectedOption); } if (!isControlled) { setSelected([...selectedOptions, selectedOption]); } break; } case "clear": if (onClear) { onClear(); } if (!isControlled) { if (withMandatoryDefaultOptions) setSelected(overrideDefaultValue); else setSelected([]); } break; } }; const DropDownComponent: React.ElementType = asyncOptions ? AsyncSelect : Select; const asyncAdditions = { ...(asyncOptions && { loadOptions: asyncOptions, cacheOptions, ...(defaultOptions && { defaultOptions }) }) }; const additions = { ...(!asyncOptions && { options }), ...(multi && { isMulti: true }) }; const closeMenuOnScroll = useCallback( (event: React.FocusEvent) => { const scrolledElement = event.target; if (scrolledElement?.parentElement?.classList.contains(menuStyles.dropdownMenuWrapper)) { return false; } return customCloseMenuOnScroll || insideOverflowContainer || insideOverflowWithTransformContainer; }, [insideOverflowContainer, insideOverflowWithTransformContainer, customCloseMenuOnScroll] ); return ( } withMandatoryDefaultOptions={withMandatoryDefaultOptions} isOptionSelected={isOptionSelected} isLoading={isLoading} loadingMessage={loadingMessage} tabSelectsValue={tabSelectsValue} filterOption={filterOption} inputValue={inputValue} blurInputOnSelect={blurInputOnSelect} {...asyncAdditions} {...additions} /> ); } ); export default withStaticProps(Dropdown, { // TODO Deprecate Dropdown.size in the next major version - use Dropdown.sizes instead size: SIZES, sizes: SIZES, chipColors: DROPDOWN_CHIP_COLORS, menuPlacements: DROPDOWN_MENU_PLACEMENT, menuPositions: DROPDOWN_MENU_POSITION, createFilter: createFilter });