import classNames from "classnames"; import { GetPropsCommonOptions, UseSelectGetLabelPropsOptions, UseSelectGetMenuPropsOptions, } from "downshift"; import { useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { usePopper, Modifier } from "react-popper-2"; import { PopperPlacement, STATUS_VARIANT } from "../../../types"; import { bemHOF } from "../../../utilities"; import { FormGroupContext } from "../../FormGroup/FormGroupContext"; import { SELECT_CREATE_OPTION_VALUE, SELECT_MENU_MAX_HEIGHT, } from "../shared/constants"; import { SelectOption } from "../shared/types"; import { useSelectOptionIndex } from "./useSelectOptionIndex"; interface UseSelectPopperProps { isOpen: boolean; hasSearch: boolean; isDisabled: boolean | undefined; variantProp: STATUS_VARIANT | undefined; getLabelProps: (options?: UseSelectGetLabelPropsOptions | undefined) => any; onCreateOption: ((newOption: string) => void) | undefined; inputValue: string | undefined; closeMenu: () => void; getMenuProps: ( options?: UseSelectGetMenuPropsOptions | undefined, otherOptions?: GetPropsCommonOptions | undefined, ) => any; transformedOptions: T[]; highlightedIndex: number; onInputChange: ((newValue: string) => void) | undefined; inputHasFocus: boolean; isCreatable: boolean; setHighlightedIndex: (index: number) => void; } const cn = bemHOF("SelectNew"); export function useSelectLayout({ isOpen, hasSearch, isDisabled, variantProp, getLabelProps, onCreateOption, inputValue, closeMenu, getMenuProps, transformedOptions, onInputChange, inputHasFocus, isCreatable, highlightedIndex, setHighlightedIndex, }: UseSelectPopperProps) { const { hasLabel: hasContextLabel, setLabelProps: setContextLabelProps, variant: variantContext, } = useContext(FormGroupContext); const variantPropOrContext = variantProp || variantContext; const variant = variantPropOrContext || STATUS_VARIANT.DEFAULT; // Send the label props to the FormGroupContext useEffect(() => { if (setContextLabelProps) { setContextLabelProps(getLabelProps()); } }, [setContextLabelProps, getLabelProps]); const initialPlacement = "bottom"; const [currentPlacement, setCurrentPlacement] = useState( initialPlacement, ); const popperModifiers: [ Modifier<"sameWidth">, Modifier<"eventListeners">, Modifier<"offset">, ] = useMemo(() => { return [ { name: "sameWidth", enabled: true, phase: "beforeWrite", requires: ["computeStyles"], fn({ state }) { setCurrentPlacement(state.placement); const newState = state; newState.styles.popper.width = `${state.rects.reference.width}px`; return newState; }, }, { name: "eventListeners", options: { scroll: isOpen, resize: isOpen, }, }, { name: "offset", options: { offset: [0, -1], }, }, ]; }, [setCurrentPlacement, isOpen]); const [referenceElement, setReferenceElement] = useState( null, ); const popperRef = useRef(null); const [callbackElement, setCallbackElement] = useState(null); const isListRendered = callbackElement !== null; const inputRef = useRef(null); const { styles, attributes, update } = usePopper( referenceElement, popperRef.current, { placement: initialPlacement, modifiers: popperModifiers, }, ); const [isPopoverVisible, setIsPopperVisible] = useState(false); useEffect(() => { if (update && isListRendered && !isPopoverVisible) { update().then(() => setIsPopperVisible(true)); } }, [update, isListRendered, isPopoverVisible]); useEffect(() => { if (!isOpen && isPopoverVisible) { setIsPopperVisible(false); } }, [isOpen, isPopoverVisible]); useEffect(() => { // If the dropdown has a search input, focus that if (isPopoverVisible && inputRef.current) { inputRef.current.focus(); return; } // Otherwise, focus the menu if (isPopoverVisible && popperRef.current) { popperRef.current.focus(); } }, [isPopoverVisible]); const isPlacementBottom = currentPlacement === "bottom"; const controlClasses = classNames( "px-3", "border", "flex", "items-center", "justify-between", "h-10", "w-full", "focus:outline-none", isDisabled ? "bg-gray-200" : "hover:bg-gray-100", isDisabled ? "cursor-not-allowed" : "cursor-pointer", !isOpen && "rounded", isOpen && isPlacementBottom ? "rounded-tr rounded-tl" : "rounded-br rounded-bl", cn({ e: "button" }), cn({ e: "button", m: variant }), ); const popperProps = { style: { ...styles.popper, maxHeight: SELECT_MENU_MAX_HEIGHT }, ...attributes.popper, className: classNames( "bg-white", "outline-none", "border", "border-gray-500", "z-modal", // This needs too be z-modal or higher to display properly inside a modal // If there's a search menu, we want to keep the scroll bar there so the // width of the list contents doesn't change as the scroll bar shows/hides hasSearch ? "overflow-y-scroll" : "overflow-y-auto", !isPopoverVisible && "invisible pointer-events-none", isPlacementBottom ? "rounded-br" : "rounded-tr", isPlacementBottom ? "rounded-bl" : "rounded-tl", isPlacementBottom ? "shadow" : "shadow-up", ), }; const wrapperClasses = classNames(isOpen && "shadow", "rounded"); const callbackElementProps = { ref: setCallbackElement, className: "hidden", }; const { optionIndexRef, setOptionIndex } = useSelectOptionIndex(); const [highlightUpdated, setHighlightUpdated] = useState(false); useEffect(() => { // When "creatable" option appears, highlight it if (isCreatable && !highlightUpdated) { setHighlightUpdated(true); setHighlightedIndex(0); } // When "creatable" option disappears, unfocus it if (!isCreatable && highlightUpdated) { setHighlightUpdated(false); setHighlightedIndex(-1); } }, [isCreatable, highlightedIndex, highlightUpdated, setHighlightedIndex]); const createOption = useCallback(() => { if (onCreateOption && inputValue) { onCreateOption(inputValue); } closeMenu(); }, [onCreateOption, inputValue, closeMenu]); const { onKeyDown, ...menuProps } = getMenuProps({ ref: popperRef }); const onMenuKeyDown = (e: KeyboardEvent) => { onKeyDown(e); // Logic fot handling tabbing forward / back within menu if (e.key === "Tab") { e.preventDefault(); if (!e.shiftKey) { const notOnLastItem = transformedOptions.length > highlightedIndex + 1; if (notOnLastItem) { setHighlightedIndex(highlightedIndex + 1); } else { setHighlightedIndex(0); } } else { const pastFirstItem = highlightedIndex > 0; if (pastFirstItem) { setHighlightedIndex(highlightedIndex - 1); } else { setHighlightedIndex(transformedOptions.length - 1); } } } // Logic for handling when the user selects a "Create" option if (e.key === "Enter" || e.key === " ") { const highlightedOption = transformedOptions[highlightedIndex]; // We don't want space bar to select an item if the user is typing in a search query const isEnterOrNoSearch = !inputHasFocus || e.key === "Enter"; if ( isEnterOrNoSearch && highlightedOption && highlightedOption.value === SELECT_CREATE_OPTION_VALUE ) { createOption(); } } // The menu captures space bar events, so we have to manually add // spaces to the search input if (e.key === " " && onInputChange && inputHasFocus) { onInputChange(`${inputValue} `); } }; const newMenuProps = { ...popperProps, ...menuProps, onKeyDown: onMenuKeyDown, }; return { setReferenceElement, popperRef, controlClasses, wrapperClasses, callbackElementProps, inputRef, hasContextLabel, optionIndexRef, setOptionIndex, menuProps: newMenuProps, createOption, }; }