"use client" import * as React from "react" import type { Placement } from "@floating-ui/react" import { autoUpdate, flip, FloatingFocusManager, FloatingList, FloatingPortal, offset, shift, useClick, useDismiss, useFloating, useInteractions, useListItem, useListNavigation, useMergeRefs, useRole, useTypeahead, } from "@floating-ui/react" import "@/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss" import { Separator } from "@/components/tiptap-ui-primitive/separator" interface DropdownMenuOptions { initialOpen?: boolean open?: boolean onOpenChange?: (open: boolean) => void side?: "top" | "right" | "bottom" | "left" align?: "start" | "center" | "end" } interface DropdownMenuProps extends DropdownMenuOptions { children: React.ReactNode } type ContextType = ReturnType & { updatePosition: ( side: "top" | "right" | "bottom" | "left", align: "start" | "center" | "end" ) => void } const DropdownMenuContext = React.createContext(null) function useDropdownMenuContext() { const context = React.useContext(DropdownMenuContext) if (!context) { throw new Error( "DropdownMenu components must be wrapped in " ) } return context } function useDropdownMenu({ initialOpen = false, open: controlledOpen, onOpenChange: setControlledOpen, side = "bottom", align = "start", }: DropdownMenuOptions) { const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen) const [currentPlacement, setCurrentPlacement] = React.useState( `${side}-${align}` as Placement ) const [activeIndex, setActiveIndex] = React.useState(null) const open = controlledOpen ?? uncontrolledOpen const setOpen = setControlledOpen ?? setUncontrolledOpen const elementsRef = React.useRef>([]) const labelsRef = React.useRef>([]) const floating = useFloating({ open, onOpenChange: setOpen, placement: currentPlacement, middleware: [offset({ mainAxis: 4 }), flip(), shift({ padding: 4 })], whileElementsMounted: autoUpdate, }) const { context } = floating const interactions = useInteractions([ useClick(context, { event: "mousedown", toggle: true, ignoreMouse: false, }), useRole(context, { role: "menu" }), useDismiss(context, { outsidePress: true, outsidePressEvent: "mousedown", }), useListNavigation(context, { listRef: elementsRef, activeIndex, onNavigate: setActiveIndex, loop: true, }), useTypeahead(context, { listRef: labelsRef, onMatch: open ? setActiveIndex : undefined, activeIndex, }), ]) const updatePosition = React.useCallback( ( newSide: "top" | "right" | "bottom" | "left", newAlign: "start" | "center" | "end" ) => { setCurrentPlacement(`${newSide}-${newAlign}` as Placement) }, [] ) return React.useMemo( () => ({ open, setOpen, activeIndex, setActiveIndex, elementsRef, labelsRef, updatePosition, ...interactions, ...floating, }), [open, setOpen, activeIndex, interactions, floating, updatePosition] ) } export function DropdownMenu({ children, ...options }: DropdownMenuProps) { const dropdown = useDropdownMenu(options) return ( {children} ) } interface DropdownMenuTriggerProps extends React.ButtonHTMLAttributes { asChild?: boolean } export const DropdownMenuTrigger = React.forwardRef< HTMLButtonElement, DropdownMenuTriggerProps >(({ children, asChild = false, ...props }, propRef) => { const context = useDropdownMenuContext() const childrenRef = React.isValidElement(children) ? parseInt(React.version, 10) >= 19 ? // eslint-disable-next-line @typescript-eslint/no-explicit-any (children as { props: { ref?: React.Ref } }).props.ref : // eslint-disable-next-line @typescript-eslint/no-explicit-any (children as any).ref : undefined const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]) if (asChild && React.isValidElement(children)) { const dataAttributes = { "data-state": context.open ? "open" : "closed", } return React.cloneElement( children, context.getReferenceProps({ ref, ...props, ...(typeof children.props === "object" ? children.props : {}), "aria-expanded": context.open, "aria-haspopup": "menu" as const, ...dataAttributes, }) ) } return ( ) }) DropdownMenuTrigger.displayName = "DropdownMenuTrigger" interface DropdownMenuContentProps extends React.HTMLAttributes { orientation?: "vertical" | "horizontal" side?: "top" | "right" | "bottom" | "left" align?: "start" | "center" | "end" portal?: boolean portalProps?: Omit, "children"> } export const DropdownMenuContent = React.forwardRef< HTMLDivElement, DropdownMenuContentProps >( ( { style, className, orientation = "vertical", side = "bottom", align = "start", portal = true, portalProps = {}, ...props }, propRef ) => { const context = useDropdownMenuContext() const ref = useMergeRefs([context.refs.setFloating, propRef]) React.useEffect(() => { context.updatePosition(side, align) }, [context, side, align]) if (!context.open) return null const content = (
{props.children}
) if (portal) { return {content} } return content } ) DropdownMenuContent.displayName = "DropdownMenuContent" interface DropdownMenuItemProps extends React.HTMLAttributes { asChild?: boolean disabled?: boolean onSelect?: () => void } export const DropdownMenuItem = React.forwardRef< HTMLDivElement, DropdownMenuItemProps >( ( { children, disabled, asChild = false, onSelect, className, ...props }, ref ) => { const context = useDropdownMenuContext() const item = useListItem({ label: disabled ? null : children?.toString() }) const isActive = context.activeIndex === item.index const handleSelect = React.useCallback( (event: React.MouseEvent) => { if (disabled) return onSelect?.() props.onClick?.(event) context.setOpen(false) }, [context, disabled, onSelect, props] ) const itemProps: React.HTMLAttributes & { ref: React.Ref role: string tabIndex: number "aria-disabled"?: boolean "data-highlighted"?: boolean } = { ref: useMergeRefs([item.ref, ref]), role: "menuitem", className, tabIndex: isActive ? 0 : -1, "data-highlighted": isActive, "aria-disabled": disabled, ...context.getItemProps({ ...props, onClick: handleSelect, }), } if (asChild && React.isValidElement(children)) { const childProps = children.props as { onClick?: (event: React.MouseEvent) => void } // Create merged props without adding onClick directly to the props object const mergedProps = { ...itemProps, ...(typeof children.props === "object" ? children.props : {}), } // Handle onClick separately based on the element type const eventHandlers = { onClick: (event: React.MouseEvent) => { // Cast the event to make it compatible with handleSelect handleSelect(event as unknown as React.MouseEvent) childProps.onClick?.(event) }, } return React.cloneElement(children, { ...mergedProps, ...eventHandlers, }) } return
{children}
} ) DropdownMenuItem.displayName = "DropdownMenuItem" interface DropdownMenuGroupProps extends React.HTMLAttributes { label?: string } export const DropdownMenuGroup = React.forwardRef< HTMLDivElement, DropdownMenuGroupProps >(({ children, label, className, ...props }, ref) => { return (
{children}
) }) DropdownMenuGroup.displayName = "DropdownMenuGroup" export const DropdownMenuSeparator = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) DropdownMenuSeparator.displayName = Separator.displayName