Popover
Displays rich content in a top layer, triggered by a button.
Loading MDX…
Loading MDX…
Install
pnpm dlx [email protected] add https://p.livog.com/r/popover.json
Code
'use client'
/**
* ⚠️ Popover implementation notes
* - Uses a React portal rather than the native top layer API so browser
* extensions like 1Password can access the DOM within the popover.
* - Stick with Floating‑UI strategy **'absolute'**. Using **'fixed'** adds scroll
* listeners and extra reflows, resulting in worse performance.
*/
import React, {
createContext,
use,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
type RefObject
} from 'react'
import { createPortal } from 'react-dom'
import { clsx } from 'clsx'
import type { PolymorphicProps } from '@/components/shared/polymorphic'
import { type Side, type Align, usePopoverPosition } from './use-popover-position'
// Broadly supported selector for potentially focusable elements.
// We intentionally avoid complex :not() attribute selectors to maximise compatibility
// across browser engines and JSDOM.
const FOCUSABLE_SELECTOR =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
const getFocusableElements = (container: HTMLElement): HTMLElement[] => {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
(el) =>
!el.hasAttribute('disabled') &&
!(el instanceof HTMLInputElement && el.type === 'hidden')
)
}
/** Focus the first focusable descendant of `container`, or the container itself. */
const focusFirstFocusable = (container: HTMLElement) => {
const next = getFocusableElements(container).at(0) ?? container
next.focus({ preventScroll: true })
}
/** Two-frame focus helper – waits for the element to be fully rendered. */
const focusFirstFocusableNextFrame = (container: HTMLElement) => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
focusFirstFocusable(container)
})
})
}
interface Refs {
trigger: RefObject<HTMLElement | null>
anchor: RefObject<HTMLElement | null>
arrow: RefObject<HTMLElement | null>
}
interface Ctx {
id: string
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
refs: Refs
side: Side
align: Align
offset: number
smart: boolean
backdropClose: boolean
escClose: boolean
}
const PopoverContext = createContext<Ctx | null>(null)
const usePopoverContext = () => {
const ctx = use(PopoverContext)
if (!ctx) throw new Error('Component must be inside <Popover>')
return ctx
}
interface PopoverProps {
children: ReactNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
side?: Side
align?: Align
offset?: number
smart?: boolean
backdropClose?: boolean
escClose?: boolean
}
type ValueOrUpdater<T> = T | ((prev: T) => T)
/**
* Generic controlled/uncontrolled value hook – mirrors React state API while
* supporting a `value` / `defaultValue` pattern.
*/
function useControllableState<T>(controlledValue: T | undefined, defaultValue: T, onChange?: (value: T) => void) {
const isControlled = controlledValue !== undefined
const [internalValue, setInternalValue] = useState(defaultValue)
const value = isControlled ? (controlledValue as T) : internalValue
const setValue = useCallback(
(next: ValueOrUpdater<T>) => {
const newValue = typeof next === 'function' ? (next as (prev: T) => T)(value) : next
if (!isControlled) setInternalValue(newValue)
onChange?.(newValue)
},
[isControlled, value, onChange]
)
return [value, setValue] as const
}
export const Popover = ({
children,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
side = 'bottom',
align = 'center',
offset = 6,
smart = true,
backdropClose = true,
escClose = true
}: PopoverProps): ReactElement => {
const id = `popover-${useId().replace(/[:«»]/g, '')}`
const [open, setOpen] = useControllableState(controlledOpen, defaultOpen, onOpenChange)
const trigger = useRef<HTMLElement>(null)
const anchor = useRef<HTMLElement>(null)
const arrow = useRef<HTMLElement>(null)
const refs = useMemo<Refs>(() => ({ trigger, anchor, arrow }), [])
const ctxValue = useMemo<Ctx>(
() => ({
id,
open,
setOpen,
refs,
side,
align,
offset,
smart,
backdropClose,
escClose
}),
[id, open, setOpen, refs, side, align, offset, smart, backdropClose, escClose]
)
return (
<PopoverContext.Provider value={ctxValue}>
<div className="popover">{children}</div>
</PopoverContext.Provider>
)
}
type TriggerOwnProps = {
children: ReactNode
className?: string
}
export type PopoverTriggerProps<E extends React.ElementType = 'button'> = PolymorphicProps<E, TriggerOwnProps>
export const PopoverTrigger = <E extends React.ElementType = 'button'>({
as,
children,
className = 'button button-outline',
...rest
}: PopoverTriggerProps<E>): ReactElement => {
const { id, setOpen, refs } = usePopoverContext()
const Comp: React.ElementType = as ?? 'button'
return (
<Comp
ref={refs.trigger}
aria-controls={id}
aria-haspopup="dialog"
onClick={() => setOpen((prev) => !prev)}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setOpen((prev) => !prev)
}
}}
className={clsx('popover-trigger', className)}
{...rest}>
{children}
</Comp>
)
}
type AnchorOwnProps = {
children?: ReactNode
className?: string
}
export type PopoverAnchorProps<E extends React.ElementType = 'span'> = PolymorphicProps<E, AnchorOwnProps>
export const PopoverAnchor = <E extends React.ElementType = 'span'>({
as,
children,
className,
...rest
}: PopoverAnchorProps<E>): ReactElement => {
const { refs } = usePopoverContext()
const Comp: React.ElementType = as ?? 'span'
return (
<Comp ref={refs.anchor} className={clsx('popover-anchor', className)} {...rest}>
{children}
</Comp>
)
}
type ContentOwnProps = {
children: ReactNode
className?: string
style?: React.CSSProperties
}
export type PopoverContentProps<E extends React.ElementType = 'div'> = PolymorphicProps<E, ContentOwnProps>
export const PopoverContent = <E extends React.ElementType = 'div'>({
as,
children,
className,
style,
...rest
}: PopoverContentProps<E>): ReactElement | null => {
const { id, open, setOpen, refs, side, align, offset, smart, backdropClose, escClose } = usePopoverContext()
const Comp: React.ElementType = as ?? 'div'
const referenceRef = refs.anchor.current ? refs.anchor : refs.trigger
const { dialogRef, setDialogRef, openPopover, closePopover } = usePopoverPosition({
open,
refs,
side,
align,
offset,
smart
})
const [mounted, setMounted] = useState(open)
const previouslyFocused = useRef<HTMLElement | null>(null)
useEffect(() => {
if (open) setMounted(true)
}, [open])
useEffect(() => {
const el = dialogRef.current
if (!el || !mounted) return
if (open) {
previouslyFocused.current = document.activeElement as HTMLElement | null
openPopover()
// Wait two animation frames: the first allows the popover to open,
// the second guarantees the content is fully rendered before we
// attempt to shift focus.
focusFirstFocusableNextFrame(el)
} else {
closePopover(() => {
setMounted(false)
previouslyFocused.current?.focus()
})
}
if (!open) return
// Use capture phase so we reliably catch events before other handlers stopPropagation
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && escClose) {
setOpen(false)
return
}
if (e.key === 'Tab') {
const tabbables = getFocusableElements(el)
if (tabbables.length === 0) {
e.preventDefault()
el.focus()
return
}
const first = tabbables[0]
const last = tabbables[tabbables.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last.focus()
}
} else if (document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
const handleClick = (e: MouseEvent) => {
if (!backdropClose || !open) return
const target = e.target as Node
if (el.contains(target) || referenceRef.current?.contains(target)) return
setTimeout(() => setOpen(false), 0)
}
addEventListener('keydown', handleKeyDown, true)
addEventListener('click', handleClick, true)
return () => {
removeEventListener('keydown', handleKeyDown, true)
removeEventListener('click', handleClick, true)
}
}, [open, setOpen, mounted, referenceRef, escClose, backdropClose, openPopover, closePopover, dialogRef])
useEffect(() => {
return () => {
closePopover()
previouslyFocused.current?.focus()
}
}, [closePopover])
if (!mounted) return null
return createPortal(
<Comp ref={setDialogRef} id={id} role="dialog" tabIndex={-1} className="popover-wrapper" {...rest}>
<div
data-align={align}
data-side={side}
style={style}
className={clsx('popover-content border-base-300 bg-base-100', className)}>
{children}
</div>
</Comp>,
document.body
)
}
export const PopoverArrow = ({ className, ...props }: React.ComponentPropsWithRef<'span'>) => {
const { refs } = usePopoverContext()
return <span ref={refs.arrow} className={clsx('popover-arrow', className)} data-popover-arrow {...props} />
}
Usage
import {
Popover,
PopoverContent,
PopoverTrigger,
PopoverArrow
} from "@/components/ui/popover"
<Popover>
<PopoverTrigger>Open</PopoverTrigger>
<PopoverContent>
<PopoverArrow /> {/* <-- optional */}
Place content for the popover here.
</PopoverContent>
</Popover>