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 startsimport { defineLtPopover } from "@lo-tek/interfaces/popover"import "@lo-tek/interfaces/tokens.css"import "@lo-tek/interfaces/popover.css"
defineLtPopover()How to use it
Section titled “How to use it”<!-- The stylesheets the page needs. Without a build step it is `<link>`, not `import`: a browser does not resolve a package name. --><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/frame/popover/popover.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/css/button/button.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/lotek.css --><!-- The component sets `popover` and `aria-controls`. You write the id on the panel: that is what the button points to. --><lt-popover id="menu" placement="bottom-end"> <button class="lt-button" data-variant="secondary" type="button">Actions</button>
<ul class="lt-popover" id="actions"> <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>
<!-- Once for the whole app, in the page template. Not once per component. --><script type="module"> import { defineLtPopover } from "https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/components/frame/popover/lt-popover.js"
defineLtPopover()</script>
<script type="module"> const menu = document.getElementById("menu") const panel = menu.querySelector(".lt-popover")
// Close the menu when an action has been chosen menu.addEventListener("click", (event) => { if (panel.contains(event.target) && event.target.closest("button")) menu.hide() })</script>// src/lotek.d.ts, gives <lt-popover> types in JSXimport "@lo-tek/interfaces/react-jsx"// main.tsx, runs once when the app startsimport { defineLtPopover } from "@lo-tek/interfaces/popover"import "@lo-tek/interfaces/popover.css"import "@lo-tek/interfaces/button.css"
defineLtPopover()import { useRef } from "react"import { lt } from "@lo-tek/interfaces/react"import "@lo-tek/interfaces/tokens.css"
export function ActionsMenu({ onArchive }: { onArchive: () => void }) { const menu = useRef<HTMLElement & { hide(): void }>(null) const box = lt.popover({ id: "actions", placement: "bottom-end" })
const choose = (action: () => void) => () => { action() menu.current?.hide() }
return ( <lt-popover ref={menu} {...box.host}> <button {...box.trigger} {...lt.button({ variant: "secondary" })}> Actions </button>
<ul {...box.panel}> <li> <button {...lt.button({ variant: "ghost" })} onClick={choose(onArchive)}> Archive the case </button> </li> </ul> </lt-popover> )}The component opens and closes the panel itself, so you need no state in React for it. To close after an action, call hide() on the element.
---import { lt } from "@lo-tek/interfaces"import "@lo-tek/interfaces/button.css"import "@lo-tek/interfaces/link.css"import "@lo-tek/interfaces/tokens.css"import "@lo-tek/interfaces/popover.css"
const box = lt.popover({ id: "actions", placement: "bottom-end" })
---
<lt-popover {...box.host}> <button {...box.trigger} {...lt.button({ variant: "secondary" })}>Actions</button>
<ul {...box.panel}> <li><a {...lt.link()} href="/case/481/pdf">Download as PDF</a></li> <li><a {...lt.link()} href="/case/481/archive">Archive the case</a></li> </ul></lt-popover>
<script> import { defineLtPopover } from "@lo-tek/interfaces/popover" defineLtPopover()</script>When the actions are links, they are in the page source and can be followed by a search engine. The panel itself is hidden by popover until the component opens it.
<!-- Datastar itself. Without it the `data-` attributes below do nothing. --><script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar.js"></script>
<!-- The stylesheets the page needs. Without a build step it is `<link>`, not `import`: a browser does not resolve a package name. --><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/frame/popover/popover.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/css/button/button.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/lotek.css -->
<div data-signals="{menuOpen: false}"> <lt-popover server-controlled placement="bottom-end" data-attr:open="$menuOpen" data-on:popover-toggle="$menuOpen = evt.detail.open" > <button class="lt-button" data-variant="secondary" type="button">Actions</button>
<ul class="lt-popover" id="actions"> <li> <button class="lt-button" data-variant="ghost" type="button" data-on:click="$menuOpen = false"> Archive the case </button> </li> </ul> </lt-popover></div>
<!-- Once for the whole app, in the page template. Not once per component. --><script type="module"> import { defineLtPopover } from "https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/components/frame/popover/lt-popover.js"
defineLtPopover()</script>data-attr:open sets and removes the attribute according to the signal, and data-on:popover-toggle picks up that the user closed the panel with Escape or a click outside. Without the latter the signal and the panel would get out of step.
server-controlled belongs with it when the signal controls open. Otherwise the component puts the attribute back when something removes it, since a removed attribute cannot be told apart from an update that tears it off, and then the panel could not be closed from the signal. If you control the panel from JavaScript instead, with menu.open = false, it is not needed: a property is a message the component can see.
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.
Attributes
Section titled “Attributes”| Attribute | Type | Effect |
|---|---|---|
open | boolean | Whether the panel is open. Mirrored when the user opens and closes it. |
placement | string | bottom-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-controlled | boolean | The 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. |
Events and methods
Section titled “Events and methods”| Name | What it is |
|---|---|
popover-toggle | Event 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.
Classes you can style
Section titled “Classes you can style”The component renders in plain DOM, so your markup is matched by ordinary selectors:
| Class | What it is |
|---|---|
.lt-popover | The panel. The component recognises it by the class or by popover, and fills in the other |
| Variable | Default |
|---|---|
--lt-popover-width | min(20rem, calc(100vw - var(--lt-spacing-8))), the maximum width. The panel is as wide as its content, up to this |
--lt-popover-padding | var(--lt-spacing-3) |
--lt-popover-radius | var(--lt-spacing-1) |
--lt-popover-top | set by the component |
--lt-popover-left | set 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.
Accessibility
Section titled “Accessibility”- 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.