import type { Placement } from "@floating-ui/react" import { FloatingFocusManager, FloatingPortal, } from "@floating-ui/react" import type { ReactElement, ReactNode } from "react" import { PANEL_SURFACE_CLASS } from "../Overlay/overlayPanelClass.ts" import { useAnchoredOverlay } from "../Overlay/useAnchoredOverlay.ts" import { toClassName } from "../toClassName.ts" export type PopoverProps = { children: ReactNode className?: string /** * Required, and it becomes the panel's `aria-label`. A * `role="dialog"` takes **no accessible name from its content**, * so an unnamed one is announced as "dialog" and is unfindable * by `getByRole("dialog", { name })` — the same trap `Spinner` * walked into in M3 with `role="status"`. */ heading: string isVisible: boolean /** * Outside press and Escape both land here, through floating-ui's * `useDismiss`. Wire it to a `useVisibility()`'s `hide`. */ onDismiss: () => void placement?: Placement /** * The control the panel hangs off. **Cloned, not wrapped** — an * extra `
` around a button is how a toolbar's layout * quietly changes. */ trigger: ReactElement } /** * Replaces mux-magic's `@radix-ui/react-popover` re-export, and it * is a rewrite rather than a swap for one reason: Radix owns * `open`, so wrapping it leaves `useVisibility` and Radix both * believing they hold it. `@floating-ui/react` is the layer Radix, * Base UI, and Ark UI are all *built on*, and it is controlled by * construction — you pass state in and it never stores any. * * What floating-ui is used for is what only it can do: collision * handling (`flip`, `shift`), a real dismiss layer, and focus * management — all of it consolidated in `useAnchoredOverlay`. * Positioning strategy and the ARIA wiring come from the platform and * from `useRole`. * * ### Portalled, not the top layer * * The panel goes in a `FloatingPortal` to `document.body`. The top * layer (`popover="manual"`) was reached for first — it needs no * portal and no `z-index` — but it does not escape an * `overflow: hidden` ancestor's *clipping* in every case the fleet * hit, and a portal does. The objection a portal used to raise, that * it moves the panel out of a scoped `canvas` query, is answered two * ways: `useRole` writes `aria-controls` on the trigger pointing at * the panel `id`, so trigger→panel stays followable across the * boundary; and the story scopes panel queries to * `within(document.body)` while the structural `expectAgentDrivable` * needs no change. `strategy: "fixed"` keeps the coordinates * viewport-relative. */ export const Popover = ({ children, className, heading, isVisible, onDismiss, placement = "bottom-start", trigger, }: PopoverProps): ReactNode => { const { clonedTrigger, context, floatingStyles, getFloatingProps, setFloating, } = useAnchoredOverlay({ isVisible, offsetValue: 8, onDismiss, placement, role: "dialog", trigger, }) return ( <> {clonedTrigger} {isVisible ? (
{children}
) : null} ) }