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

OptionDescription
getAnchorThe element the popup sits beside.
getFloatingThe popup surface.
getOpenWhether the popup is open.
getPlacementA RuiPlacement, such as bottom-start. Read when the popup is positioned.
gapPixels between anchor and popup. Default 8.
portalMount the popup in document.body while open. Default true; false leaves it in place.
matchAnchorWidthGive the popup the anchor width.
getPortalContainerWhere a portaled popup goes instead of document.body, read each time the popup opens. document.body when it returns null.
onDismissCalled when the user dismisses the open popup. Dismissal listens only when this is set. Read on each attach(); updateConfig does not change it.
dismissOnWhich 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.

HookDescription
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.