{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"popover","type":"registry:ui","title":"Popover","description":"An anchored panel that opens next to a trigger and holds interactive content — quick settings, a small form, more than a tooltip can carry","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["overlays"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/popover/Popover.tsx","content":"\"use client\";\n\nimport { forwardRef, useCallback, useMemo, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, MouseEvent, ReactNode } from \"react\";\n\nimport type { Side, Align } from \"../../internals/anchor-position\";\nimport { useLiveRef } from \"../../internals/dom/use-live-ref\";\nimport { useFancyId } from \"../../internals/use-id\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport { PopoverContent } from \"./PopoverContent\";\nimport { POPOVER_KEY } from \"./types\";\nimport type { PopoverContext } from \"./types\";\nimport \"./popover.css\";\n\ninterface PopoverOwnProps {\n\t/**\n\t * Whether the panel is open.\n\t *\n\t * The source declares this `$bindable(false)`; React has no two-way\n\t * channel, so the component keeps its own copy either way and re-syncs it\n\t * whenever the CALLER changes the prop. That is what makes all three\n\t * documented call shapes work off one implementation — a caller driving\n\t * `open` from its own state, a caller who passes only `onOpenChange`, and\n\t * a caller who passes a plain unbound value alongside it.\n\t */\n\topen?: boolean;\n\t/** Called with the new value whenever the panel opens or closes, however the change happened. */\n\tonOpenChange?: (open: boolean) => void;\n\t/** Side of the trigger to place the panel on. */\n\tside?: Side;\n\t/** Alignment along the trigger's cross axis. */\n\talign?: Align;\n\t/** Gap in pixels between the trigger and the panel. */\n\toffset?: number;\n\t/** Whether Escape and an outside click close the panel. */\n\tdismissible?: boolean;\n\t/**\n\t * The trigger's content. Rendered inside the real `<button>` this\n\t * component owns — keep it to text/icons, not another interactive\n\t * control, or activation ends up on two nested buttons at once.\n\t */\n\ttrigger?: ReactNode;\n\t/** The panel's content. */\n\tchildren?: ReactNode;\n\t/** Additional CSS classes, merged onto the panel. */\n\tclassName?: string;\n\t/**\n\t * Plays the matching open/close cue through the sound controller. Off by\n\t * default; only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\n/**\n * `className` and `ref` target the panel; other attributes, including `style`, go to the trigger\n * button, as do native listeners. The managed ARIA, state and structural attributes stay the\n * component's.\n */\nexport interface PopoverProps\n\textends Omit<ComponentPropsWithoutRef<\"button\">, keyof PopoverOwnProps>, PopoverOwnProps {}\n\n/**\n * The disclosure root: the real `<button>` trigger, and the portalled panel\n * it anchors.\n *\n * The source declares `ref = $bindable(null)` for the PANEL element, so the\n * panel arrives through this component's ref channel (PORTING.md\n * §\"API contract\") rather than through a prop.\n */\nexport const Popover = forwardRef<HTMLDivElement, PopoverProps>(function Popover(\n\t{\n\t\topen: openProp,\n\t\tonOpenChange,\n\t\tside = \"bottom\",\n\t\talign = \"center\",\n\t\toffset = 8,\n\t\tdismissible = true,\n\t\ttrigger,\n\t\tchildren,\n\t\tclassName,\n\t\tsound = false,\n\t\tonClick,\n\t\t...rest\n\t},\n\tforwardedRef\n) {\n\t// The trigger's `aria-controls` target. The panel isn't in the DOM at all\n\t// until `open` is true, so the attribute itself is gated on `open` too\n\t// (see the button below) — otherwise it would reference this id for the\n\t// entire closed lifetime of the component, which is most of it, with\n\t// nothing in the DOM behind it. `useFancyId()`, not `uid()`, because it\n\t// needs to agree with itself from the first server-rendered paint, not\n\t// just after hydration (convention C-6).\n\tconst contentId = useFancyId();\n\n\t// The React shape of the source's `open = $bindable(false)`: an internal\n\t// copy seeded from the prop, re-synced during render whenever the CALLER\n\t// changes the prop, and free to move on its own in between.\n\t//\n\t// Re-synced in the render path, not an effect: an effect would paint one\n\t// frame of the stale value first, and the pattern React documents for\n\t// \"adjust state when a prop changes\" is exactly this — set state on the\n\t// component that owns it, during render, and let React restart the render\n\t// before committing anything.\n\tconst [open, setOpenState] = useState(openProp ?? false);\n\tconst [lastOpenProp, setLastOpenProp] = useState(openProp);\n\tif (lastOpenProp !== openProp) {\n\t\tsetLastOpenProp(openProp);\n\t\tsetOpenState(openProp ?? false);\n\t}\n\n\t// A plain ref, not state: nothing renders off it. The panel reads it\n\t// lazily, through an anchor getter and an exclude getter that both run\n\t// after the trigger has committed — the button is this component's own\n\t// first child, so its ref is attached before the panel's subtree runs any\n\t// effect of its own.\n\tconst triggerRef = useRef<HTMLButtonElement | null>(null);\n\n\t// The one place `open` changes. A plain function, not an effect —\n\t// writing `open` there would mean reading and writing the same state in\n\t// one pass, and would fight a caller's own controlled write. The guard is\n\t// what makes a dismiss that changes nothing fire nothing: a second Escape\n\t// during the fade must not call `onOpenChange(false)` a second time.\n\tconst openRef = useLiveRef(open);\n\tconst playCue = useSoundCue(sound);\n\tconst setOpen = useCallback(\n\t\t(next: boolean) => {\n\t\t\tif (openRef.current === next) return;\n\t\t\tsetOpenState(next);\n\t\t\t// Behind the same guard as `onOpenChange`, so a dismiss that changes\n\t\t\t// nothing — a second Escape racing the exit — stays silent instead\n\t\t\t// of doubling the cue. Every open/close path in the family routes\n\t\t\t// through here: the trigger's `toggle()`, and every dismissal\n\t\t\t// through the context's `close()`. `playCue` is a no-op while the\n\t\t\t// prop is off, so no second guard is needed here.\n\t\t\tplayCue(next ? \"open\" : \"close\");\n\t\t\tonOpenChange?.(next);\n\t\t},\n\t\t[openRef, onOpenChange, playCue]\n\t);\n\n\tfunction toggle(event: MouseEvent<HTMLButtonElement>) {\n\t\tsetOpen(!open);\n\t\tonClick?.(event);\n\t}\n\n\tconst close = useCallback(() => {\n\t\tsetOpen(false);\n\t}, [setOpen]);\n\n\t// A plain object rebuilt when its scalar inputs change — the rebuild is\n\t// what makes the panel re-render. `triggerRef` stays a getter over its\n\t// ref, which is precisely what it is on the source side.\n\tconst context = useMemo<PopoverContext>(\n\t\t() => ({\n\t\t\tcontentId,\n\t\t\tside,\n\t\t\talign,\n\t\t\toffset,\n\t\t\tdismissible,\n\t\t\topen,\n\t\t\tget triggerRef() {\n\t\t\t\treturn triggerRef.current;\n\t\t\t},\n\t\t\tclose,\n\t\t}),\n\t\t[contentId, side, align, offset, dismissible, open, close]\n\t);\n\n\treturn (\n\t\t<POPOVER_KEY.Provider value={context}>\n\t\t\t<button\n\t\t\t\t{...rest}\n\t\t\t\tref={triggerRef}\n\t\t\t\ttype=\"button\"\n\t\t\t\tclassName=\"ft-popover-trigger border-border text-foreground hover:bg-accent hover:text-accent-foreground inline-flex cursor-pointer items-center gap-1.5 rounded-[8px] border px-[14px] py-[7px] text-[12px] font-medium transition-colors focus-visible:ring-[3px] focus-visible:ring-[var(--ft-overlay-accent)]/35 focus-visible:outline-none\"\n\t\t\t\taria-expanded={open}\n\t\t\t\taria-controls={open ? contentId : undefined}\n\t\t\t\tonClick={toggle}\n\t\t\t>\n\t\t\t\t{trigger}\n\t\t\t</button>\n\t\t\t{/*\n\t\t\t\tRendered unconditionally where the source wraps it in `{#if open}`.\n\t\t\t\tThe panel now owns its own mount clock (`usePresence`), which is\n\t\t\t\twhat keeps it on screen for the length of its exit — the job the\n\t\t\t\tsource's branch-plus-outro did — and hoisting it above the gate is\n\t\t\t\talso what lets its `Portal` resolve a container BEFORE the commit\n\t\t\t\tthat opens the panel, so the entrance leg has a node to attach to.\n\t\t\t*/}\n\t\t\t<PopoverContent ref={forwardedRef} className={className}>\n\t\t\t\t{children}\n\t\t\t</PopoverContent>\n\t\t</POPOVER_KEY.Provider>\n\t);\n});\n\nPopover.displayName = \"Popover\";\n","type":"registry:ui","target":"@ui/fancy/components/popover/Popover.tsx"},{"path":"react/src/components/popover/PopoverContent.tsx","content":"\"use client\";\n\nimport { forwardRef } from \"react\";\nimport type { ReactNode } from \"react\";\n\nimport { cn } from \"../../utils\";\nimport { Portal } from \"../../internals/Portal\";\nimport { useAnchorPosition } from \"../../internals/use-anchor-position\";\nimport { useDismissable } from \"../../internals/dismissable\";\nimport { useFocusTrap } from \"../../internals/focus-trap\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { anchored, originFor } from \"../../internals/motion/anchored\";\nimport { usePresence } from \"../../internals/motion/presence\";\nimport { usePopoverContext } from \"./types\";\n\nexport interface PopoverContentProps {\n\t/** The panel's content, forwarded straight from `Popover`'s own `children`. */\n\tchildren?: ReactNode;\n\t/** Additional CSS classes, merged onto the panel. */\n\tclassName?: string;\n}\n\n/**\n * The portalled, anchored panel.\n *\n * No `role` here on purpose. A popover is a disclosure, not a dialog — it\n * has no title to hang `aria-labelledby` off (the `trigger` node is whatever\n * the caller passed, not a documented \"title\"), and `role=\"dialog\"` without\n * an accessible name is worse than no role at all. Reachability comes from\n * `useFocusTrap` (moves focus in, and returns it to the trigger the instant\n * the panel is dismissed) and `useDismissable`, not from a landmark role.\n *\n * The source declares `ref = $bindable(null)`, so the panel element arrives\n * through the ref channel rather than a prop.\n */\nexport const PopoverContent = forwardRef<HTMLDivElement, PopoverContentProps>(\n\tfunction PopoverContent({ children, className }, forwardedRef) {\n\t\t// `Popover` only ever mounts this component under its own provider, so\n\t\t// the context is always present by the time this runs — there is no\n\t\t// standalone-usage fallback to design for.\n\t\tconst ctx = usePopoverContext();\n\n\t\t// The NODE, not a ref (convention C-1): the panel is created by\n\t\t// `presence.mounted`, so a `useRef` read inside a `[]`-deps effect\n\t\t// would still be null when the trap, position and dismiss effects fire\n\t\t// and none of them would ever arm — silently.\n\t\tconst [panel, setPanelNode] = useElementRef<HTMLDivElement>();\n\n\t\t// The placement as ACTUALLY resolved — the requested side and align\n\t\t// until `computePosition` flips or clamps it away from a viewport edge.\n\t\t// Seeded with the REQUESTED values by the hook itself rather than a\n\t\t// hardcoded \"bottom\"/\"center\", so a panel that never flips reads the\n\t\t// right growth origin without depending on whether the first placement\n\t\t// has run yet, and the common case never shows a one-frame origin jump.\n\t\t//\n\t\t// `resolvedAlign` differs from the requested alignment whenever\n\t\t// clamping slid the panel along the cross axis — near a viewport edge\n\t\t// the requested corner is no longer the one touching the anchor, and an\n\t\t// entrance grown from it would expand from the far corner instead.\n\t\t//\n\t\t// DECLARED BEFORE `useFocusTrap`, and that order is load-bearing: both\n\t\t// arm in layout effects in the commit the panel node lands in, and\n\t\t// layout effects run in hook-declaration order. Positioning first means\n\t\t// the trap's `.focus()` lands on a panel that already has coordinates.\n\t\t// The other way round it would focus a `position: static` node sitting\n\t\t// at the end of `document.body`, and the browser would scroll the page\n\t\t// down to it — a jump that survives the switch to `position: fixed` one\n\t\t// effect later. The source gets this from its action order\n\t\t// (`use:anchorPosition` before `use:focusTrap`).\n\t\tconst { side: resolvedSide, align: resolvedAlign } = useAnchorPosition(panel, {\n\t\t\tanchor: () => ctx.triggerRef,\n\t\t\tside: ctx.side,\n\t\t\talign: ctx.align,\n\t\t\toffset: ctx.offset,\n\t\t});\n\n\t\t// Returns the two functions the source hands out through `onActivate`\n\t\t// (divergence D-3): the eager return, and the re-arm. Two module-level\n\t\t// `let`s, two handler functions and an `onActivate` closure collapse to\n\t\t// this one line.\n\t\tconst trap = useFocusTrap(panel, { returnFocus: true });\n\n\t\tconst presence = usePresence(ctx.open, {\n\t\t\t// The two halves of the focus handshake, at the two moments the\n\t\t\t// source puts them: `onintrostart` → rearm, `onoutrostart` →\n\t\t\t// returnFocusNow.\n\t\t\t//\n\t\t\t// `returnFocusNow` at the dismiss instant is the whole point:\n\t\t\t// waiting for the trap's own `destroy()` would strand a keyboard\n\t\t\t// user on `<body>` for the whole length of the fade, because the\n\t\t\t// panel is marked `inert` the instant the exit starts.\n\t\t\t//\n\t\t\t// `rearm` is the other half. A popover reopened DURING its fade\n\t\t\t// reverses the exit instead of remounting, so the trap is never\n\t\t\t// re-created: without this the panel would come back interactive\n\t\t\t// with focus left on the trigger behind it, Tab walking the page\n\t\t\t// rather than the panel, and the eager return already spent for the\n\t\t\t// life of the instance.\n\t\t\tonEnterStart: () => trap.rearm(),\n\t\t\tonExitStart: () => trap.returnFocusNow(),\n\t\t});\n\n\t\t// `active: ctx.open` — a plain boolean where the source needed a getter\n\t\t// (divergence D-6). The layer stays ON the stack for its whole exit and\n\t\t// stops being TOP of it the instant `open` flips, so a second Escape\n\t\t// during the fade is neither answered again nor swallowed on its way to\n\t\t// whatever sits underneath.\n\t\tuseDismissable(panel, {\n\t\t\tonDismiss: ctx.close,\n\t\t\tescape: ctx.dismissible,\n\t\t\toutsideClick: ctx.dismissible,\n\t\t\texclude: () => [ctx.triggerRef],\n\t\t\tactive: ctx.open,\n\t\t\t// Never modal: keep focus only through the press that dismisses (focus is\n\t\t\t// back on the trigger). A non-dismissible popover leaves outside presses\n\t\t\t// alone, so the page keeps text selection and blur while it holds focus.\n\t\t\tkeepFocus: ctx.dismissible,\n\t\t});\n\n\t\t// Convention C-2: composed ABOVE the conditional below. Calling this\n\t\t// inside the JSX branch would be a conditional hook and would throw the\n\t\t// first time `mounted` flips.\n\t\t//\n\t\t// ONE bidirectional transition, never a split in/out pair: the\n\t\t// in-flight counterpart's current position is passed into the fresh\n\t\t// leg, so a panel reopened mid-exit continues from where it is instead\n\t\t// of snapping to invisible first. `entering` is what tells it which way\n\t\t// it is going, and the params are read at the instant each leg starts.\n\t\tconst panelRef = useComposedRefs(\n\t\t\tsetPanelNode,\n\t\t\tforwardedRef,\n\t\t\tpresence.register(anchored, (entering) => ({ side: resolvedSide, entering }))\n\t\t);\n\n\t\tconst classes = cn(\n\t\t\t\"ft-popover-content flex w-max flex-col gap-[6px] rounded-[10px] border border-border bg-popover px-[14px] py-[12px] text-[12px] text-popover-foreground shadow-lg outline-none\",\n\t\t\tclassName\n\t\t);\n\n\t\t// The `Portal` stays ABOVE the mounted gate, and the gate wraps its\n\t\t// CHILDREN. `usePortalTarget` resolves its container in a layout\n\t\t// effect, so a `Portal` that first mounts in the same commit as the\n\t\t// panel would render null on that pass — `usePresence` would then find\n\t\t// no registered leg and the entrance would be silently skipped.\n\t\t//\n\t\t// No portal-before-focus-trap ordering ceremony (divergence D-10):\n\t\t// `createPortal` commits children into the container before any effect\n\t\t// runs and refs populate before layout effects, so the node the trap\n\t\t// focuses is always connected.\n\t\t//\n\t\t// `data-state` is an ordinary attribute (divergence D-2) carrying\n\t\t// `surfaceState`'s TWO values — never `\"opening\"` (convention C-5).\n\t\t// `inert` is not written by hand either: `usePresence` sets it on every\n\t\t// registered node for the whole exit, which is what keeps a fading\n\t\t// panel from being clicked or tabbed into on its way out.\n\t\t//\n\t\t// `data-align` publishes the REQUESTED alignment, exactly as the source\n\t\t// does; only the transform origin follows the resolved one.\n\t\treturn (\n\t\t\t<Portal>\n\t\t\t\t{presence.mounted ? (\n\t\t\t\t\t<div\n\t\t\t\t\t\tref={panelRef}\n\t\t\t\t\t\tid={ctx.contentId}\n\t\t\t\t\t\tclassName={classes}\n\t\t\t\t\t\tdata-state={presence.surfaceState}\n\t\t\t\t\t\tdata-side={resolvedSide}\n\t\t\t\t\t\tdata-align={ctx.align}\n\t\t\t\t\t\tstyle={{ transformOrigin: originFor(resolvedSide, resolvedAlign) }}\n\t\t\t\t\t>\n\t\t\t\t\t\t{children}\n\t\t\t\t\t</div>\n\t\t\t\t) : null}\n\t\t\t</Portal>\n\t\t);\n\t}\n);\n\nPopoverContent.displayName = \"PopoverContent\";\n","type":"registry:ui","target":"@ui/fancy/components/popover/PopoverContent.tsx"},{"path":"react/src/components/popover/index.ts","content":"\"use client\";\n\nexport { Popover } from \"./Popover\";\nexport type { PopoverProps } from \"./Popover\";\nexport { PopoverContent } from \"./PopoverContent\";\nexport type { PopoverContentProps } from \"./PopoverContent\";\n\n// The context key, kept because the source's own `index.ts` exports it (as a\n// `unique symbol` there, as the React context object here) — the public\n// escape hatch for a consumer composing their own panel content.\nexport { POPOVER_KEY } from \"./types\";\nexport type { PopoverContext } from \"./types\";\n","type":"registry:ui","target":"@ui/fancy/components/popover/index.ts"},{"path":"react/src/components/popover/popover.css","content":"/*\n * Colocated port of `Popover`'s `<style>` block. Anchored on\n * `.ft-popover-trigger`, which is already the first token of the trigger's\n * own class string, so nothing was added to that literal.\n *\n * The custom property keeps its local fallback: the component must not depend\n * on an app-level token existing.\n */\n.ft-popover-trigger {\n\t--ft-overlay-accent: var(\n\t\t--ft-accent,\n\t\tlight-dark(oklch(0.5432 0.2528 300.22), oklch(0.604 0.2606 301.75))\n\t);\n}\n","type":"registry:ui","target":"@ui/fancy/components/popover/popover.css"},{"path":"react/src/components/popover/types.ts","content":"\"use client\";\n\n/**\n * The contract between the `Popover` root and its portalled content panel,\n * and the React context that carries it.\n *\n * The source publishes this contract under a `unique symbol` context key;\n * React's own context object plays that role here, so the exported name is\n * kept and the value is a `React.Context` object rather than a symbol — the\n * same substitution the menu family makes for `MENU_KEY`:\n *\n * ```tsx\n * const popover = useContext(POPOVER_KEY);\n * ```\n */\n\nimport { createContext, useContext } from \"react\";\nimport type { Side, Align } from \"../../internals/anchor-position\";\n\n/**\n * The contract between the `Popover` root and its portalled content panel.\n *\n * The root owns everything that decides *whether* and *where* the panel\n * renders — the open flag, the trigger element to anchor against, and the\n * positioning/dismiss knobs the caller passed in. The content panel only\n * ever reads this; it never sets `open` itself (it closes through `close()`,\n * which routes back through the root so `onOpenChange` still fires).\n */\nexport interface PopoverContext {\n\t/** The panel's own id — also what the trigger's `aria-controls` points at. */\n\treadonly contentId: string;\n\t/** Side of the trigger to place the panel on. */\n\treadonly side: Side;\n\t/** Alignment along the trigger's cross axis. */\n\treadonly align: Align;\n\t/** Gap in pixels between the trigger and the panel. */\n\treadonly offset: number;\n\t/** Whether Escape and an outside click are allowed to close the panel. */\n\treadonly dismissible: boolean;\n\t/** The real trigger button, once mounted — what the panel anchors against. */\n\treadonly triggerRef: HTMLElement | null;\n\t/**\n\t * Whether the panel is open. Read by the panel's own presence clock to\n\t * tell an entrance from a departure, and by `useDismissable`'s `active`\n\t * gate, so a panel that is already fading stops answering Escape and\n\t * stops counting as the top layer.\n\t *\n\t * A plain boolean where the source needed a getter (divergence D-6):\n\t * React re-renders the still-mounted exiting panel normally, so every\n\t * reader sees the current value without a getter having to defeat an\n\t * inert-effect scheduler.\n\t *\n\t * The panel still never *writes* this: it closes through `close()`, which\n\t * routes back through the root so `onOpenChange` fires exactly once.\n\t */\n\treadonly open: boolean;\n\t/** Closes the panel, going through the root's own `open` state so `onOpenChange` fires. */\n\tclose(): void;\n}\n\nexport const POPOVER_KEY = createContext<PopoverContext | undefined>(undefined);\nPOPOVER_KEY.displayName = \"PopoverContext\";\n\n/**\n * Reads the enclosing `Popover`'s context.\n *\n * `Popover` only ever mounts `PopoverContent` under its own provider, so\n * there is no standalone-usage fallback to design for — the same assumption\n * the source's own content component makes about its root. Internal: not\n * exported from `index.ts`.\n */\nexport function usePopoverContext(): PopoverContext {\n\tconst value = useContext(POPOVER_KEY);\n\tif (value === undefined) {\n\t\tthrow new Error(\n\t\t\t\"PopoverContext is missing: this component must be rendered inside its provider.\"\n\t\t);\n\t}\n\treturn value;\n}\n","type":"registry:ui","target":"@ui/fancy/components/popover/types.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/popover?framework=react"}}