Skip to content

Popover

The panel uses the browser’s own popover, so it ends up in the top layer and sits above everything else without anyone having to find a z-index that is high enough.

The component does the rest. The panel is opened in manual mode, not auto, since the browser’s own light dismiss closes the panel before the button gets its click, and the button then cannot be used to close it again. Escape, click outside and focus back to the button are therefore written in the component. If you write popover without a value by hand, the panel is auto, and then the component follows along when the browser closes it.

The positioning is also ours. position-anchor is not yet available in all browsers, so the component computes the position relative to the button and sets it as two variables on the panel.

// main.ts, runs once when the app starts
import { defineLtPopover } from "@lo-tek/interfaces/popover"
import "@lo-tek/interfaces/tokens.css"
import "@lo-tek/interfaces/popover.css"
defineLtPopover()
const box = lt.popover({ id: "actions", placement: "bottom-end" })
<lt-popover placement="bottom-end">
<button class="lt-button" data-variant="secondary" type="button"
aria-expanded="false" aria-controls="actions">Actions</button>
<ul class="lt-popover" id="actions" popover="manual">
<li><button class="lt-button" data-variant="ghost" type="button">Archive the case</button></li>
<li><button class="lt-button" data-variant="ghost" type="button">Download as PDF</button></li>
</ul>
</lt-popover>

The component recognises the panel by popover or the class lt-popover, and the button by aria-controls pointing to the panel, otherwise by it being the first button outside the panel that does not point to something else with aria-controls.

When the markup is produced with JavaScript, lt.popover() writes the wiring, the id, the class and popover. When it comes from a template without JavaScript, the template writes the id on the panel, and the component sets popover="manual" and aria-controls, and leaves what the server wrote in place. The component also sets open on the host, aria-expanded on the button and the position on the panel. Everything it wrote, it puts back when an update from the server tears it away.

The repair goes one way: if someone has asked for the popover to be open, it stays open. If the server sends open, it opens, because that is something the server actually said.

Close with the property, menu.open = false, hide() or toggle(). That is a message the component can see. The difference between them is the event: hide() and toggle() send popover-toggle, the property does not. If you remove the attribute from outside instead, as with Datastar’s data-attr:open, it cannot be told apart from an update that tears it off, and then the component puts it back. There server-controlled is the answer. See Markup and behaviour.

AttributeTypeEffect
openbooleanWhether the panel is open. Mirrored when the user opens and closes it.
placementstringbottom-start, bottom-end, top-start or top-end. start and end follow the reading direction, so on a page with dir="rtl" start is the right edge. If there is no room along the edge, the panel is pushed into the window rather than switching to the other. lt.popover({ id, placement }) writes it on the host, lt.popover.isPlacement checks a value, and popoverPlacements from the main entry point lists them
server-controlledbooleanThe server decides whether the panel is open. The component does not put open back when an update from the server removes it, as it does without the attribute.
NameWhat it is
popover-toggleEvent with { open: boolean } in detail. Sent when the user opens or closes the panel, and from show(), hide() and toggle(), not when open is set as a property or attribute
show(), hide(), toggle()Methods on the element
const menu = document.querySelector("lt-popover")
menu.addEventListener("popover-toggle", (event) => {
console.log(event.detail.open ? "opened" : "closed")
})

The panel moves with the page when the user scrolls or resizes the window. If there is no room below the button, it goes above.

The component renders in plain DOM, so your markup is matched by ordinary selectors:

ClassWhat it is
.lt-popoverThe panel. The component recognises it by the class or by popover, and fills in the other
VariableDefault
--lt-popover-widthmin(20rem, calc(100vw - var(--lt-spacing-8))), the maximum width. The panel is as wide as its content, up to this
--lt-popover-paddingvar(--lt-spacing-3)
--lt-popover-radiusvar(--lt-spacing-1)
--lt-popover-topset by the component
--lt-popover-leftset by the component

The position is set as --lt-popover-top and --lt-popover-left on the element. The two are computed from the button’s place on the screen, and are the only values the component writes directly. Everything else is in the stylesheet, so you can override it without !important.

See Customising for how the variables and the layer work.

  • The panel is not a dialog. Focus is not moved inside automatically, and the rest of the page is still usable. If you need to stop the user, use Dialog.
  • Escape closes it and returns focus to the button. Without that, focus is on something that no longer exists.
  • The content of the panel should be reachable with Tab, in the same order as in the markup.
  • Do not put a form that must be filled in inside a panel that closes on a click outside.