Files
2026-03-09 22:30:41 +00:00

199 lines
5.4 KiB
TypeScript

'use client'
import React, { useState, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { classNames } from './classNames'
export type PopperPlacement =
| 'top'
| 'top-start'
| 'top-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
| 'left'
| 'left-start'
| 'left-end'
| 'right'
| 'right-start'
| 'right-end'
export interface PopperChildrenProps {
placement: PopperPlacement
TransitionProps?: { in: boolean }
}
export interface PopperProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'> {
anchorEl?: HTMLElement | (() => HTMLElement) | null
children: React.ReactNode | ((props: PopperChildrenProps) => React.ReactNode)
container?: HTMLElement | (() => HTMLElement)
disablePortal?: boolean
keepMounted?: boolean
modifiers?: any[]
open?: boolean
placement?: PopperPlacement
popperOptions?: any
popperRef?: React.Ref<HTMLDivElement>
transition?: boolean
/** Test ID for automated testing */
testId?: string
}
/**
* Popper - A low-level utility component for positioning floating elements
*/
export function Popper({
anchorEl,
children,
container,
disablePortal = false,
keepMounted = false,
modifiers = [],
open = false,
placement = 'bottom',
popperOptions = {},
popperRef,
transition = false,
className,
style,
testId,
...props
}: PopperProps) {
const [position, setPosition] = useState({ top: 0, left: 0 })
const [actualPlacement, setActualPlacement] = useState<PopperPlacement>(placement)
const popperElementRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!open || !anchorEl) return
const updatePosition = () => {
const anchor = typeof anchorEl === 'function' ? anchorEl() : anchorEl
if (!anchor) return
const anchorRect = anchor.getBoundingClientRect()
const popperElement = popperElementRef.current
const popperRect = popperElement?.getBoundingClientRect() || { width: 0, height: 0 }
let top = 0
let left = 0
const finalPlacement = placement
// Calculate position based on placement
switch (placement) {
case 'top':
top = anchorRect.top - popperRect.height
left = anchorRect.left + (anchorRect.width - popperRect.width) / 2
break
case 'top-start':
top = anchorRect.top - popperRect.height
left = anchorRect.left
break
case 'top-end':
top = anchorRect.top - popperRect.height
left = anchorRect.right - popperRect.width
break
case 'bottom':
top = anchorRect.bottom
left = anchorRect.left + (anchorRect.width - popperRect.width) / 2
break
case 'bottom-start':
top = anchorRect.bottom
left = anchorRect.left
break
case 'bottom-end':
top = anchorRect.bottom
left = anchorRect.right - popperRect.width
break
case 'left':
top = anchorRect.top + (anchorRect.height - popperRect.height) / 2
left = anchorRect.left - popperRect.width
break
case 'left-start':
top = anchorRect.top
left = anchorRect.left - popperRect.width
break
case 'left-end':
top = anchorRect.bottom - popperRect.height
left = anchorRect.left - popperRect.width
break
case 'right':
top = anchorRect.top + (anchorRect.height - popperRect.height) / 2
left = anchorRect.right
break
case 'right-start':
top = anchorRect.top
left = anchorRect.right
break
case 'right-end':
top = anchorRect.bottom - popperRect.height
left = anchorRect.right
break
default:
top = anchorRect.bottom
left = anchorRect.left
}
// Add scroll offset
top += window.scrollY
left += window.scrollX
setPosition({ top, left })
setActualPlacement(finalPlacement)
}
updatePosition()
window.addEventListener('scroll', updatePosition, true)
window.addEventListener('resize', updatePosition)
return () => {
window.removeEventListener('scroll', updatePosition, true)
window.removeEventListener('resize', updatePosition)
}
}, [open, anchorEl, placement])
if (!open && !keepMounted) {
return null
}
const content = (
<div
ref={(node) => {
popperElementRef.current = node
if (popperRef) {
if (typeof popperRef === 'function') {
popperRef(node)
} else {
(popperRef as React.MutableRefObject<HTMLDivElement | null>).current = node
}
}
}}
className={classNames('fakemui-popper', className, {
'fakemui-popper-hidden': !open,
})}
style={{
position: 'absolute',
top: position.top,
left: position.left,
...style,
}}
data-popper-placement={actualPlacement}
data-testid={testId}
{...props}
>
{typeof children === 'function'
? children({ placement: actualPlacement, TransitionProps: transition ? { in: open } : undefined })
: children}
</div>
)
if (disablePortal) {
return content
}
const containerElement = container ? (typeof container === 'function' ? container() : container) : document.body
return createPortal(content, containerElement)
}
export default Popper