import type { HTMLAttributes, MouseEvent as ReactMouseEvent, ReactNode } from 'react' import { cloneElement, isValidElement, useCallback, useEffect, useRef, } from 'react' import { cx } from '../../utils/cx' import { useIsomorphicLayoutEffect } from '../../hooks/useIsomorphicLayoutEffect' import { useAutoId } from '../../utils/useId' import { usePressFeedback } from '../../hooks/usePressFeedback' import { useReducedMotion } from '../../hooks/useReducedMotion' import { FOCUSABLE, useDismissLayer, useExitDelay, useOpenState } from '../../overlay' /** Which edge of the anchor the panel sits against. */ export type PopoverSide = 'top' | 'bottom' | 'left' | 'right' /** Where the panel sits along that edge. */ export type PopoverAlign = 'start' | 'center' | 'end' /** `'bottom'`, `'bottom-start'`, `'left-end'`, … — a side, optionally aligned. */ export type PopoverPlacement = PopoverSide | `${PopoverSide}-${Exclude}` const OPPOSITE: Record = { top: 'bottom', bottom: 'top', left: 'right', right: 'left', } /** * These four are geometry, not design values, so they are plain numbers rather * than tokens: they feed `getBoundingClientRect` arithmetic, and reading a * custom property back out of the cascade on every scroll frame would cost a * forced style recalc for no gain. */ /** Default gap between anchor and panel — `--may-space-2` expressed in px. */ const DEFAULT_OFFSET = 8 /** Closest the panel is ever allowed to get to a viewport edge. */ const VIEWPORT_PADDING = 8 /** How far the arrow protrudes: half the diagonal of a `--may-space-3` square. */ const ARROW_REACH = 8 /** Keeps the arrow off the rounded corners — `--may-radius-sheet` plus the tip. */ const ARROW_MARGIN = 24 /** Matches `--may-duration-fast`; the panel stays mounted this long to animate out. */ const EXIT_MS = 150 export interface FloatingPosition { x: number y: number side: PopoverSide align: PopoverAlign /** The anchor's centre, in px from the panel's leading edge along its cross axis. */ anchorOffset: number /** Room the chosen side actually has, so a long panel scrolls instead of overflowing. */ maxHeight: number } /** * Anchored placement with flip and shift, in viewport coordinates. * * Shared with Tooltip rather than duplicated: the flip rule below is the kind * of decision that goes subtly wrong when two copies drift apart. * * Exported from this module, deliberately not from the package barrel — it is * an implementation detail of the overlay family, not public API. */ export function placeFloating( anchor: DOMRect, panel: { width: number; height: number }, placement: PopoverPlacement, gap: number, /* * The element the placement is relative to, read only for its writing * direction. `start` and `end` are documented as inline terms — "so a * mirrored layout needs no second set of values" — and this resolved them * to physical left and right: measured under `dir="rtl"`, a * `bottom-start` popover aligned its LEFT edge with the anchor's left, * where inline-start is the right one. The side names stay physical, * because `left` means left in any direction. * * Passed as an element rather than a flag so a subtree with its own `dir` * is right; omitted, it falls back to the document. */ relativeTo?: Element | null, ): FloatingPosition { // clientWidth/Height rather than innerWidth/Height: they exclude a classic // scrollbar, which is exactly the strip a fixed panel must not sit under. const vw = document.documentElement.clientWidth const vh = document.documentElement.clientHeight const [preferred, requested] = placement.split('-') as [PopoverSide, PopoverAlign | undefined] const align: PopoverAlign = requested ?? 'center' const room: Record = { top: anchor.top - VIEWPORT_PADDING, bottom: vh - anchor.bottom - VIEWPORT_PADDING, left: anchor.left - VIEWPORT_PADDING, right: vw - anchor.right - VIEWPORT_PADDING, } const vertical = preferred === 'top' || preferred === 'bottom' const needed = (vertical ? panel.height : panel.width) + gap const opposite = OPPOSITE[preferred] // Flip only when the preferred side cannot fit AND the opposite side has more // room. Flipping into an equally cramped side just moves the clipping, and // the panel jitters between the two as the page scrolls. const side = room[preferred] >= needed || room[opposite] <= room[preferred] ? preferred : opposite const onVerticalAxis = side === 'top' || side === 'bottom' const maxHeight = onVerticalAxis ? room[side] - gap : vh - VIEWPORT_PADDING * 2 const height = Math.min(panel.height, maxHeight) const width = Math.min(panel.width, vw - VIEWPORT_PADDING * 2) let x = 0 let y = 0 if (side === 'top') y = anchor.top - gap - height else if (side === 'bottom') y = anchor.bottom + gap else if (side === 'left') x = anchor.left - gap - width else x = anchor.right + gap const anchorStart = onVerticalAxis ? anchor.left : anchor.top const anchorSpan = onVerticalAxis ? anchor.width : anchor.height const panelSpan = onVerticalAxis ? width : height // Only the horizontal cross axis mirrors; a left/right placement aligns on // the block axis, which has no inline direction to follow. const mirrored = onVerticalAxis && getComputedStyle(relativeTo ?? document.documentElement).direction === 'rtl' const resolved: PopoverAlign = !mirrored || align === 'center' ? align : align === 'start' ? 'end' : 'start' let cross = anchorStart + (anchorSpan - panelSpan) / 2 if (resolved === 'start') cross = anchorStart else if (resolved === 'end') cross = anchorStart + anchorSpan - panelSpan // Shift back inside the viewport. This runs after the flip so it corrects the // side the panel actually landed on, and the outer Math.max keeps a panel // wider than the viewport pinned to the leading edge rather than the trailing. const limit = (onVerticalAxis ? vw : vh) - VIEWPORT_PADDING - panelSpan cross = Math.max(VIEWPORT_PADDING, Math.min(cross, Math.max(VIEWPORT_PADDING, limit))) if (onVerticalAxis) x = cross else y = cross const margin = Math.min(ARROW_MARGIN, panelSpan / 2) const centre = anchorStart + anchorSpan / 2 - cross const anchorOffset = Math.max(margin, Math.min(centre, panelSpan - margin)) // Whole pixels: a panel at a half-pixel offset renders its text blurred on a // 1x display, and the difference is invisible on every other one. return { x: Math.round(x), y: Math.round(y), side, align, anchorOffset: Math.round(anchorOffset), maxHeight: Math.round(maxHeight), } } /* * `useExitDelay` moved to `src/overlay` — it is overlay lifetime, not popover * behaviour, and Modal, Sheet, AlertDialog, ActionSheet and CommandPalette all * took it from here. Re-exported so that import path keeps working. */ export { useExitDelay } from '../../overlay' /** Props Popover injects into whatever element it is handed as a trigger. */ interface TriggerProps { onClick?: (event: ReactMouseEvent) => void 'aria-haspopup'?: 'dialog' | 'menu' | 'listbox' 'aria-expanded'?: boolean 'aria-controls'?: string } /* * Extends `HTMLAttributes` for the same reason Modal does: a consumer needs to * attach `data-testid` for an e2e run, an `id`, or an extra `aria-*`, and a * closed prop type refuses all three. `role` is omitted because this component * narrows it to the two values a popover surface may carry. */ export interface PopoverProps extends Omit, 'role'> { /** * The anchor. A React element is cloned so the open state lands on the real * control; anything else is wrapped in a plain button, because a popover you * cannot reach from the keyboard is not a popover. * * Popovers render inline rather than through a portal, so do not put one * inside an ARIA-structured container — a `role="list"`, `tablist`, * `radiogroup` or `menu`. The wrapper holding the trigger and panel becomes * a child the container's role does not allow, which axe reports as * aria-required-children. Put it beside the container instead. */ trigger: ReactNode children?: ReactNode /** @default 'bottom' */ placement?: PopoverPlacement /** Visual gap between anchor and panel, in px. @default 8 */ offset?: number /** Controlled open state. */ open?: boolean /** Uncontrolled initial state. @default false */ defaultOpen?: boolean onOpenChange?: (open: boolean) => void /** Draw the pointer arrow. @default true */ arrow?: boolean /** * `'none'` renders the panel as a plain box, for content that carries its own * role — a Menu owning its items, for instance. @default 'dialog' */ role?: 'dialog' | 'none' /** What the trigger advertises it opens. @default 'dialog' */ haspopup?: 'dialog' | 'menu' | 'listbox' /** Move focus into the panel when it opens. @default true */ autoFocus?: boolean /** Inset the panel's content. Turn off for edge-to-edge rows. @default true */ padded?: boolean /** Class for the inline wrapper around the trigger. */ className?: string /** Class for the floating panel itself. */ surfaceClassName?: string 'aria-label'?: string } /** * An anchored floating panel. * * Positioned from a live `getBoundingClientRect`, with a flip to the opposite * side and a shift along the edge when the viewport would clip it — recomputed * on scroll and resize, so a panel never detaches from its trigger. * * It is deliberately non-modal: no scrim, no focus trap, no scroll lock. A * popover that locks the page is a Sheet, and this system already has one. * Dismissal is the full native set — outside press, Escape, and focus leaving * the panel — with focus handed back to the trigger on the paths where the * user did not choose somewhere else to put it. */ export function Popover({ trigger, children, placement = 'bottom', offset = DEFAULT_OFFSET, open: openProp, defaultOpen = false, onOpenChange, arrow = true, role = 'dialog', haspopup = 'dialog', autoFocus = true, padded = true, className, surfaceClassName, 'aria-label': ariaLabel, ...rest }: PopoverProps) { const { open, setOpen } = useOpenState({ open: openProp, defaultOpen, onOpenChange }) const reducedMotion = useReducedMotion() const mounted = useExitDelay(open, reducedMotion ? 0 : EXIT_MS) const rootRef = useRef(null) const anchorRef = useRef(null) const surfaceRef = useRef(null) /** Set when the close came from focus going somewhere the user chose. */ const skipRestore = useRef(false) const wasOpen = useRef(open) const surfaceId = useAutoId() const { pressProps } = usePressFeedback() /* ------------------------------- placement ------------------------------ */ const gap = offset + (arrow ? ARROW_REACH : 0) const reposition = useCallback(() => { const anchor = anchorRef.current const surface = surfaceRef.current if (!anchor || !surface) return // offsetWidth/offsetHeight rather than a rect: during the entrance the panel // is mid-scale, and a rect would measure the animated size and walk the // panel away from its anchor frame by frame. surface.style.maxHeight = '' const panel = { width: surface.offsetWidth, height: surface.offsetHeight } const next = placeFloating(anchor.getBoundingClientRect(), panel, placement, gap, anchor) surface.style.left = `${next.x}px` surface.style.top = `${next.y}px` surface.style.maxHeight = `${next.maxHeight}px` surface.style.setProperty('--may-popover-anchor', `${next.anchorOffset}px`) surface.dataset.side = next.side surface.dataset.align = next.align // Flipped last, in the same frame: the entrance animation is keyed off it, // so it can only start once the panel knows where it is. surface.dataset.positioned = 'true' }, [placement, gap]) /* * `open` sits in the deps alongside `mounted`: reopening inside the exit * window reuses the very same node, and without this the panel would come * back wherever its anchor happened to be when it left. */ useIsomorphicLayoutEffect(() => { if (!mounted) return reposition() /* * Capture phase: scroll does not bubble, and the panel has to follow an * ancestor scroller as readily as the window. * * But a scroll INSIDE the panel is not the anchor moving, and repositioning * on it breaks the panel outright: `reposition` clears `max-height` to take * an unconstrained measurement, which stops the body overflowing for that * instant, and the browser clamps its scrollTop back to 0. Every wheel tick * therefore undid itself and a long menu could not be scrolled at all. */ const onScroll = (event: Event) => { if (surfaceRef.current?.contains(event.target as Node)) return reposition() } window.addEventListener('scroll', onScroll, true) window.addEventListener('resize', onScroll) // The anchor, not the panel — observing the panel would re-fire on the // max-height this very function writes. const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => reposition()) if (anchorRef.current) observer?.observe(anchorRef.current) return () => { window.removeEventListener('scroll', onScroll, true) window.removeEventListener('resize', onScroll) observer?.disconnect() } }, [mounted, open, reposition]) /* ------------------------------- dismissal ------------------------------ */ const requestClose = useCallback( (restoreFocus: boolean) => { skipRestore.current = !restoreFocus setOpen(false) }, [setOpen], ) /* * The topmost dismissible layer owns Escape: without this a popover inside a * Sheet would take the sheet down with it. That ordering now comes from the * shared layer stack rather than from listener registration order, which is * what made the old capture-phase listener lose this race to the Sheet. */ useDismissLayer({ open, onClose: useCallback(() => requestClose(true), [requestClose]) }) useEffect(() => { if (!open) return const onPointerDown = (event: PointerEvent) => { if (rootRef.current?.contains(event.target as Node)) return requestClose(false) } document.addEventListener('pointerdown', onPointerDown, true) return () => document.removeEventListener('pointerdown', onPointerDown, true) }, [open, requestClose]) /* --------------------------------- focus -------------------------------- */ useEffect(() => { if (!open || !autoFocus) return const surface = surfaceRef.current if (!surface) return const target = surface.querySelector(FOCUSABLE) ?? surface // preventScroll: the panel is already inside the viewport by construction, // and letting the browser scroll to it would drag the page under it. target.focus({ preventScroll: true }) }, [open, autoFocus]) useEffect(() => { if (wasOpen.current && !open) { const holdingFocus = surfaceRef.current?.contains(document.activeElement) if (holdingFocus && !skipRestore.current) { const back = anchorRef.current?.querySelector(FOCUSABLE) back?.focus({ preventScroll: true }) } skipRestore.current = false } wasOpen.current = open }, [open]) /* -------------------------------- trigger ------------------------------- */ const toggle = (event: ReactMouseEvent) => { if (event.defaultPrevented) return if (open) requestClose(true) else setOpen(true) } const injected: TriggerProps = { 'aria-haspopup': haspopup, 'aria-expanded': open, 'aria-controls': mounted ? surfaceId : undefined, } const triggerNode = isValidElement(trigger) ? ( cloneElement(trigger, { ...injected, onClick: (event: ReactMouseEvent) => { trigger.props.onClick?.(event) toggle(event) }, }) ) : ( ) return ( { // relatedTarget is null when focus goes nowhere in particular — pressing // a non-focusable area inside the panel, or the window losing focus. // Treating that as "focus left" would close the panel out from under a // user who has not gone anywhere. const next = event.relatedTarget as Node | null if (!open || !next || rootRef.current?.contains(next)) return requestClose(false) }} > {triggerNode} {mounted && (
{arrow && }
{children}
)}
) }