PopoverController
Portal mount, floating position, dismissal and teardown of a popup.
Reach for it for a popup, hover card or menu panel beside an anchor.
Import
import { PopoverController, createPopoverController } from '@ecopages/radiant-ui/controllers';Try it
The button toggles an open flag and calls sync(), which shows the panel beside the button. A press outside, or focus leaving, calls onDismiss. The panel is portaled into the example frame with getPortalContainer; without it the panel goes to document.body, and portal: false keeps it where it is.
<div data-example="popover">
<button type="button">Toggle</button>
<div data-box hidden>Popover content</div>
</div>import { PopoverController } from '@ecopages/radiant-ui/controllers';
export function mount(root: HTMLElement): () => void {
const trigger = root.querySelector('button')!;
const panel = root.querySelector<HTMLElement>('[data-box]')!;
let open = false;
const popover = new PopoverController({
getAnchor: () => trigger,
getFloating: () => panel,
getOpen: () => open,
getPlacement: () => 'bottom-start',
getPortalContainer: () => root,
onDismiss: () => {
open = false;
popover.sync();
},
});
popover.attach();
trigger.addEventListener('click', () => {
open = !open;
popover.sync();
});
return () => popover.destroy();
}mount runs once for each example element on the page, and the function it returns undoes it; see how the examples run.
Config
| Option | Description |
|---|---|
getAnchor | The element the popup sits beside. |
getFloating | The popup surface. |
getOpen | Whether the popup is open. |
getPlacement | A RuiPlacement, such as bottom-start. Read when the popup is positioned. |
gap | Pixels between anchor and popup. Default 8. |
portal | Mount the popup in document.body while open. Default true; false leaves it in place. |
matchAnchorWidth | Give the popup the anchor width. |
getPortalContainer | Where a portaled popup goes instead of document.body, read each time the popup opens. document.body when it returns null. |
onDismiss | Called when the user dismisses the open popup. Dismissal listens only when this is set. Read on each attach(); updateConfig does not change it. |
dismissOn | Which dismissals onDismiss hears about. Defaults to all. Read once, when the controller is created. |
Lifecycle
attach() enables sync() and, when onDismiss is set, dismissal. sync() does nothing before attach() or after destroy(); call it whenever open changes. destroy() removes the floating listeners, ends dismissal, returns a portaled popup to its place and forgets the floating element. teardown() closes the popup the same way but stays attached, so the next sync() mounts it again. Also updateConfig(patch), contains(node) and isOutside(target). Dismissal counts a portaled popup that came from inside the surface (a portaled select or calendar inside the popover) as inside, so working in it does not close the popover.
With a host, createPopoverController(host, config) does the same on connect and disconnect; see bindController.
Protected hooks
Extend the class and override these to change how it decides. A component never extends a controller; a subclass is registered with bindController.
| Hook | Description |
|---|---|
position(anchor, floating) | Decides where the open popup sits. Runs from sync() each time the popover is open. Return a cleanup function; it runs before the next position(), when the popover closes and on destroy(). The default is attachFloating with the configured placement, gap and matchAnchorWidth. |