import React, { useState, useEffect, useRef, forwardRef, useImperativeHandle, ReactNode, useCallback } from "react"; import ReactDOM from "react-dom"; import cn from "classnames"; import { Manager, Reference, Popper } from "react-popper"; import { getPressedKey, KEYS } from "../utils/keyboard"; import { Button } from "../"; type ButtonVariants = | "primary" | "secondary" | "success" | "danger" | "warning" | "info" | "dark" | "light" | "link" | "white" | "outline-primary" | "outline-secondary" | "outline-success" | "outline-danger" | "outline-warning" | "outline-info" | "outline-dark" | "outline-light" | "outline-white"; export interface IDropdownProps { ["data-testid"]?: string; arrow?: boolean; autoClose?: boolean; btnClassName?: string; btnRef?: React.RefObject; btnSize?: "lg" | "sm"; btnVariant?: ButtonVariants; children?: React.ReactNode; className?: string; disabled?: boolean; dropdownMenuClassName?: string; header: ReactNode; id?: string; maxHeight?: number | string; maxWidth?: number | string; minHeight?: number | string; minWidth?: number | string; onClose?: () => void; onToggle?: (isOpen: boolean | null) => void; position?: "right" | "left"; useCapture?: boolean; useFormControl?: boolean; width?: number; } export interface InputHandlers { closeDropdown(): void; } export interface IPopoverWrapperProps { children: React.ReactNode; scheduleUpdate: () => void; } function useKeyboard( itemsContainerRef: React.RefObject, isOpen: boolean | null, setOpen: React.Dispatch>, autoClose?: boolean, useCapture?: boolean ) { // ESC should close dropdown // Arrows down/up should navigate to next/prev (or first) element // by WAI-ARIA Authoring Practices useEffect(() => { // Avoid adding global event listener if dropdown is not open if (!isOpen) { return; } const handler = (e: KeyboardEvent) => { const key = getPressedKey(e); if (key === KEYS.Escape) { setOpen(false); return; } if (!itemsContainerRef.current || !e.target) { return; } if (key !== "ArrowDown" && key !== "ArrowUp") { return; } const items: HTMLElement[] = [].slice.call( itemsContainerRef.current.querySelectorAll( ".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)" ) ); if (!items.length) { return; } let currentFocusedIndex = items.indexOf(e.target as HTMLElement); if (key === "ArrowUp" && currentFocusedIndex > 0) { currentFocusedIndex--; } if (key === "ArrowDown" && currentFocusedIndex < items.length - 1) { currentFocusedIndex++; } if (currentFocusedIndex < 0) { currentFocusedIndex = 0; } items[currentFocusedIndex].focus(); e.preventDefault(); }; document.addEventListener("keydown", handler, useCapture); return () => { document.removeEventListener("keydown", handler, useCapture); }; }, [isOpen]); // Automatically close on container if Enter pressed const onContainerKeyPress = useCallback( (e: React.KeyboardEvent) => { if (!autoClose) { return; } const key = getPressedKey(e); if (key === KEYS.Enter) { setOpen(false); } }, [autoClose] ); return onContainerKeyPress; } function useDropdownContainer() { const portalContainerRef = useRef(); // Creating container for dropdown due to React problems with appending to body // https://stackoverflow.com/questions/49504546/is-it-safe-to-use-reactdom-createportal-with-document-body useEffect(() => { let portalContainer = document.querySelector("#honeyui-dropdown-container") as HTMLDivElement; // Trying to reuse container to avoid creation multiple DOM Nodes, each for every dropdown if (!portalContainer) { portalContainer = document.createElement("div"); portalContainer.setAttribute("id", "honeyui-dropdown-container"); document.body.appendChild(portalContainer); } portalContainerRef.current = portalContainer; }, []); return portalContainerRef; } const PopoverWrapper: React.FC = props => { const { scheduleUpdate, children } = props; const mounted = useRef(false); useEffect(() => { if (!mounted.current) { mounted.current = true; } else { scheduleUpdate(); } }, [children]); return <>{children}; }; const Dropdown = forwardRef((props, ref) => { const { arrow, autoClose, btnClassName, btnRef, btnSize, btnVariant, children, className, disabled, dropdownMenuClassName, header, id, maxHeight, maxWidth, minHeight, minWidth, onClose, onToggle, position, useCapture, useFormControl } = props; const testId = props["data-testid"] || id || "honeyui-dropdown"; const [isOpen, setOpen] = useState(null); const defaultButtonRef = useRef(null); const itemsContainerRef = useRef(null); const portalContainerRef = useDropdownContainer(); const onContainerKeyPress = useKeyboard( itemsContainerRef, isOpen, setOpen, autoClose, useCapture ); useImperativeHandle(ref, () => ({ closeDropdown() { setOpen(false); } })); // Global click should close dropdown useEffect(() => { // Avoid adding global event listener if dropdown is not open if (!isOpen) { return; } const handler = (e: MouseEvent) => { if (!itemsContainerRef.current) { return; } if (!(e.target instanceof Element)) { return; } if (itemsContainerRef.current.contains(e.target)) { return; } if ((btnRef || defaultButtonRef).current?.contains(e.target)) { return; } setOpen(false); }; document.addEventListener("click", handler, useCapture); return () => { document.removeEventListener("click", handler, useCapture); }; }, [isOpen]); useEffect(() => { if (isOpen === false && onClose) { onClose(); } if (onToggle) { onToggle(isOpen); } }, [isOpen]); return ( {({ ref }) => (
)}
{/* eslint-disable @typescript-eslint/indent */} {isOpen && portalContainerRef.current ? ReactDOM.createPortal( {({ ref, style, placement, scheduleUpdate }) => ( // zIndex: 1050 is modal zIndex so 1051 is next one
autoClose && setOpen(false)} onKeyPress={onContainerKeyPress} data-testid={`${testId}-dropdown-body`} aria-labelledby="dropdownMenuButton" > {children}
)}
, portalContainerRef.current ) : null} {/* eslint-enable */}
); }); Dropdown.displayName = "Dropdown"; Dropdown.defaultProps = { arrow: false, btnVariant: "primary", id: "honeyui-dropdown", maxHeight: 284, maxWidth: 300, minHeight: 0, minWidth: 0, position: "right", useCapture: false, useFormControl: false }; export default Dropdown;