import { FloatingArrow, FloatingFocusManager, FloatingPortal, Side, arrow, autoUpdate, flip, offset, safePolygon, shift, useClick, useDismiss, useFloating, useFocus, useHover, useInteractions, useRole, } from "@floating-ui/react"; import clsx from "clsx"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { Button, ButtonProps } from "../button"; import { ToolbarProps } from "../toolbar"; import "./dropdown.scss"; export type DropdownProps = ButtonProps & { /** Button content. */ label: React.ReactNode; /** If true, the dropdown is opened when hovered. */ activateOnHover?: boolean; /** Preferred placement position. */ placement?: Side; /** Whether the dropdown should be open. */ open?: boolean; /** The items shown inside the dropdown. */ items?: ToolbarProps["items"]; /** Whether to render the dropdown content as Toolbar. */ toolbar?: boolean; /** Any additional props to pass to the toolbar. */ toolbarProps?: Omit; // toolbarProps?: unknown; /** Gets called when the dropdown is opened. */ onOpen?: () => void; /** Gets called when the dropdown is closed. */ onClose?: () => void; }; type TOOLBAR_MODULE_STUB = { Toolbar: React.FC; ToolbarProps: ToolbarProps; ToolbarSpacer: () => unknown; }; /** * Dropdown component, behaves much like a button except it's label should be * passed using the `label` prop. `Children` should be a `Toolbar` component containing * the individual items. * @param children * @param className * @param hover * @param label * @param open * @param placement * @param props * @constructor */ export const Dropdown: React.FC = ({ children, className, activateOnHover = false, label, open = false, placement = "bottom", items, toolbar = true, toolbarProps, onOpen, onClose, ...props }) => { const arrowRef = useRef(null); // FIXME: Experimental approach for avoiding circular dependency. const [toolbarModuleState, setToolbarModuleState] = useState(null); useEffect(() => { if (!toolbarModuleState) { import("../toolbar/toolbar").then((toolbarModule) => setToolbarModuleState(toolbarModule as TOOLBAR_MODULE_STUB), ); } }, [toolbar]); const [isOpen, setIsOpen] = useState(false); /** * Gets called when the floating-ui open state is altered. */ const handleOpenChange = useCallback( (open: boolean) => { setIsOpen(open); if (open) { onOpen?.(); } else if (!open) { onClose?.(); } }, [onOpen, onClose], ); /** * Sync isOpen with open prop. */ useEffect(() => setIsOpen(open), [open]); /** * Initialize Floating UI. */ const { refs, floatingStyles, middlewareData, context } = useFloating({ middleware: [ offset(6), flip(), arrow({ element: arrowRef }), shift({ padding: 0 }), ], open: isOpen, onOpenChange: handleOpenChange, placement: placement, strategy: "fixed", whileElementsMounted: autoUpdate, }); const focus = useFocus(context, { enabled: activateOnHover, }); const hover = useHover(context, { enabled: activateOnHover, handleClose: safePolygon(), }); const click = useClick(context); const dismiss = useDismiss(context); const role = useRole(context); // Merge all the interactions into prop getters const { getReferenceProps, getFloatingProps } = useInteractions([ click, focus, hover, dismiss, role, ]); const isToolbarModuleLoaded = ( toolbarModuleState: TOOLBAR_MODULE_STUB | null, ): toolbarModuleState is TOOLBAR_MODULE_STUB => Boolean(toolbarModuleState); /** * Renders the dropdown content. */ const renderContent = useCallback(() => { if (toolbar && isToolbarModuleLoaded(toolbarModuleState)) { return ( {children} ); } return children; }, [toolbar, toolbarModuleState, items, toolbarProps, children]); return (
{isOpen && (
{renderContent()}
)}
); };