Manual UI Behaviors pushLayer

pushLayer

elements man ui/behaviors/dismiss

A dismiss layer: Escape and an outside press close an overlay, with a stack so only the top layer reacts. A layer pushed while another is open sits above it. Escape goes to the top layer only, so closing a menu inside a dialog leaves the dialog open and the next Escape closes the dialog; an outside press goes to the top layer and then down the stack to every layer it is also outside of, so a press outside nested popovers closes them all.

Signatures
function pushLayer(el: HTMLElement, options: LayerOptions): Layer; function layerCount(): number; function topLayer(): Layer | undefined; function onScrollbar(e: PointerEvent): boolean; type DismissReason = "escape" | "outside" | "focus-outside";

Options

Option Type Default What it does
onDismiss (reason: DismissReason, event: Event) => void required Called when the layer should close.
escape boolean | (() => boolean) true Escape dismisses. A function is read at the moment of the key press.
outside boolean | ((event: PointerEvent | MouseEvent) => boolean) true A pointer press outside dismisses. A function gets the event and returns whether it dismisses.
focusOutside boolean | (() => boolean) false Focus moving outside dismisses, for a non-modal popover.
onEscapeKeyDown (event: KeyboardEvent) => void none Called before an Escape dismiss; event.preventDefault() keeps the layer open.
onPointerDownOutside (event: PointerEvent) => void none Called before an outside dismiss; event.preventDefault() keeps the layer open.
onFocusOutside (event: FocusEvent) => void none Called before a focus-outside dismiss; event.preventDefault() keeps the layer open.
onInteractOutside (event: PointerEvent | MouseEvent | FocusEvent) => void none Called before either outside dismiss, after the specific callback; event.preventDefault() keeps the layer open.
ancestorScroll boolean false Scrolling an element that contains an inside() element (the trigger) dismisses, with reason "outside" and the scroll event.
inside () => (Element | null | undefined)[] none Elements that count as inside the layer, such as its trigger.
blockOutside boolean false Blocks pointer interaction with everything below the layer while it is open, for a modal menu. The press that lands outside still dismisses.
bubbles boolean | { escapeKey?: boolean; outsidePress?: boolean } { escapeKey: false, outsidePress: true } How a dismiss travels to the layers below. escapeKey: true lets one Escape close a whole tree; outsidePress: false stops an outside press at this layer. A boolean sets both.
outsidePressEvent "pointerdown" | "mousedown" | "click" "pointerdown" Which event an outside mouse or pen press dismisses on. A touch always waits for the click.
capture boolean | { escapeKey?: boolean; outsidePress?: boolean } true Listen in the capture phase, so a handler that stops the event still dismisses. false waits for the event to bubble to the document, so stopPropagation() in a handler keeps the layer open.

"Outside" means the event target is not inside the layer element, not inside any element inside() returns, and not inside a layer stacked above: a nested popover rendered elsewhere still counts as inside its parent while it is open. Mouse and pen dismiss on pointerdown; touch dismisses on the click that follows, so a scroll gesture that starts outside does not close the layer. An outside press stops below a modal layer (blockOutside, or a layer inside a modal <dialog>), at a layer whose callback prevented the dismiss, and at a layer the press is inside: a press inside a parent popover closes only the child. Focus moving outside closes every focusOutside layer it left, top first.

An Escape that belongs to an IME composition never dismisses. A press on an element injected at the top of the page after the layer opened (a browser extension's popup) is not outside, and neither is a press on the scrollbar of the element pressed. onScrollbar(e) is that last test, exported for a template's own pointer handling.

The layer

Member What it does
el, options What the layer was pushed with.
isTop() True while this is the topmost layer.
release() Removes the layer from the stack. Safe to call twice.

In a template of your own

app/shared/templates/popup/index.ehtml
import { pushLayer, type Layer } from "@elements/ui/behaviors"; interface PopupState { trigger?: HTMLElement; layer?: Layer; } <Popup (open: boolean = false, private ui: PopupState = {})> <button type="button" aria-expanded={open ? "true" : "false"} oninsert={(e) => ui.trigger = e.target as HTMLElement} onclick={() => open = !open}><slot name="trigger"/></button> <div e:if={open} class="popup" oninsert={(e) => ui.layer = pushLayer(e.target as HTMLElement, { onDismiss: () => open = false, inside: () => [ui.trigger], })} onremove={() => ui.layer?.release()}> <slot/> </div> </Popup>

The trigger is inside, so clicking it toggles the popup instead of dismissing and reopening it. Pair the layer with float (opens in a new tab) to place the popup and with focusScope (opens in a new tab) when it takes focus.

Writes

data-layer="<depth>" on the element. With blockOutside, pointer-events: none on <body> and auto on every open layer, put back when the last blocking layer releases.

Used by

Chart (opens in a new tab), Combobox (opens in a new tab), Dialog (opens in a new tab), DropdownMenu (opens in a new tab), ContextMenu (opens in a new tab), Menubar (opens in a new tab), NavigationMenu (opens in a new tab), Popover (opens in a new tab), Select (opens in a new tab). onScrollbar: Kanban (opens in a new tab), RichText (opens in a new tab), TagsInput (opens in a new tab).