casualroom/apps/fenghuo/web/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx

389 lines
9.9 KiB
TypeScript
Raw Normal View History

2025-07-28 07:50:50 +08:00
"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<typeof useDropdownMenu> & {
updatePosition: (
side: "top" | "right" | "bottom" | "left",
align: "start" | "center" | "end"
) => void
}
const DropdownMenuContext = React.createContext<ContextType | null>(null)
function useDropdownMenuContext() {
const context = React.useContext(DropdownMenuContext)
if (!context) {
throw new Error(
"DropdownMenu components must be wrapped in <DropdownMenu />"
)
}
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<Placement>(
`${side}-${align}` as Placement
)
const [activeIndex, setActiveIndex] = React.useState<number | null>(null)
const open = controlledOpen ?? uncontrolledOpen
const setOpen = setControlledOpen ?? setUncontrolledOpen
const elementsRef = React.useRef<Array<HTMLElement | null>>([])
const labelsRef = React.useRef<Array<string | null>>([])
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 (
<DropdownMenuContext.Provider value={dropdown}>
<FloatingList
elementsRef={dropdown.elementsRef}
labelsRef={dropdown.labelsRef}
>
{children}
</FloatingList>
</DropdownMenuContext.Provider>
)
}
interface DropdownMenuTriggerProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
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<any> } }).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 (
<button
ref={ref}
aria-expanded={context.open}
aria-haspopup="menu"
data-state={context.open ? "open" : "closed"}
{...context.getReferenceProps(props)}
>
{children}
</button>
)
})
DropdownMenuTrigger.displayName = "DropdownMenuTrigger"
interface DropdownMenuContentProps
extends React.HTMLAttributes<HTMLDivElement> {
orientation?: "vertical" | "horizontal"
side?: "top" | "right" | "bottom" | "left"
align?: "start" | "center" | "end"
portal?: boolean
portalProps?: Omit<React.ComponentProps<typeof FloatingPortal>, "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 = (
<FloatingFocusManager
context={context.context}
modal={false}
initialFocus={0}
returnFocus={true}
>
<div
ref={ref}
className={`tiptap-dropdown-menu ${className || ""}`}
style={{
position: context.strategy,
top: context.y ?? 0,
left: context.x ?? 0,
outline: "none",
...style,
}}
aria-orientation={orientation}
data-orientation={orientation}
data-state={context.open ? "open" : "closed"}
data-side={side}
data-align={align}
{...context.getFloatingProps(props)}
>
{props.children}
</div>
</FloatingFocusManager>
)
if (portal) {
return <FloatingPortal {...portalProps}>{content}</FloatingPortal>
}
return content
}
)
DropdownMenuContent.displayName = "DropdownMenuContent"
interface DropdownMenuItemProps extends React.HTMLAttributes<HTMLDivElement> {
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<HTMLDivElement>) => {
if (disabled) return
onSelect?.()
props.onClick?.(event)
context.setOpen(false)
},
[context, disabled, onSelect, props]
)
const itemProps: React.HTMLAttributes<HTMLDivElement> & {
ref: React.Ref<HTMLDivElement>
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<HTMLElement>) => 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<HTMLElement>) => {
// Cast the event to make it compatible with handleSelect
handleSelect(event as unknown as React.MouseEvent<HTMLDivElement>)
childProps.onClick?.(event)
},
}
return React.cloneElement(children, {
...mergedProps,
...eventHandlers,
})
}
return <div {...itemProps}>{children}</div>
}
)
DropdownMenuItem.displayName = "DropdownMenuItem"
interface DropdownMenuGroupProps extends React.HTMLAttributes<HTMLDivElement> {
label?: string
}
export const DropdownMenuGroup = React.forwardRef<
HTMLDivElement,
DropdownMenuGroupProps
>(({ children, label, className, ...props }, ref) => {
return (
<div
{...props}
ref={ref}
role="group"
aria-label={label}
className={`tiptap-button-group ${className || ""}`}
>
{children}
</div>
)
})
DropdownMenuGroup.displayName = "DropdownMenuGroup"
export const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof Separator>,
React.ComponentPropsWithoutRef<typeof Separator>
>(({ className, ...props }, ref) => (
<Separator
ref={ref}
className={`tiptap-dropdown-menu-separator ${className || ""}`}
{...props}
/>
))
DropdownMenuSeparator.displayName = Separator.displayName