389 lines
9.9 KiB
TypeScript
Executable File
389 lines
9.9 KiB
TypeScript
Executable File
"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
|