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.40.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/frame/popover/popover.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/css/button/button.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.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>
<!-- Each action posts to the server, so the menu closes when the next page arrives, and no script has to close it. --> <ul class="lt-popover" id="actions"> <li> <form action="/loans/LDG-117/return" method="post"> <button class="lt-button" data-variant="ghost">Mark LDG-117 returned</button> </form> </li> <li> <form action="/loans/LDG-117/late-fee" method="post"> <button class="lt-button" data-variant="ghost">Charge the late fee</button> </form> </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.40.0/dist/components/frame/popover/lt-popover.js"
defineLtPopover()</script>If an action does not leave the page, as when it saves with fetch, close the menu with menu.hide() when the answer has come.
A form cannot stand inside another form. If the menu is inside one, the browser drops the inner <form> tags, the first action submits the outer form, and the outer form’s own buttons after the menu fall out of it. Put the action forms outside it, and point each button at its form with form="…".
// 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({ onReturned }: { onReturned: () => 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(onReturned)}> Mark LDG-117 returned </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="/breakers/LDG-117/history">Rental history</a></li> <li><a {...lt.link()} href="/breakers/LDG-117/terms">Rental terms</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.40.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/frame/popover/popover.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.0/src/components/css/button/button.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.40.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"> Mark LDG-117 returned </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.40.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.
On a Kotlin server you call Lt.popover(), here with kotlinx.html and lo-tek-kotlinx-html. The id is the panel’s, and the button points to it. Each action is a form that posts, so the menu closes when the server answers with the next page, and the recipe has no script beyond the registration. kotlinx.html writes a form inside a form without a word, and the browser then drops the inner one: if the menu is inside a form, put the action forms outside it and point each button at its form with attributes["form"].
import kotlinx.html.*import no.lotek.*import no.lotek.html.ltimport no.lotek.html.ltPopover
fun FlowContent.actionsMenu() { val menu = Lt.popover(id = "actions", placement = PopoverPlacement.BOTTOM_END) ltPopover { id = "menu" lt(menu.host) button { lt(Lt.button(variant = ButtonVariant.SECONDARY), menu.trigger); type = ButtonType.button; +"Actions" } ul { lt(menu.panel) li { form(action = "/loans/LDG-117/return", method = FormMethod.post) { button { lt(Lt.button(variant = ButtonVariant.GHOST)); +"Mark LDG-117 returned" } } } li { form(action = "/loans/LDG-117/late-fee", method = FormMethod.post) { button { lt(Lt.button(variant = ButtonVariant.GHOST)); +"Charge the late fee" } } } } }}// Once for the whole app, in the page template. Not once per component.head { link(rel = "stylesheet", href = LotekWebJar.CSS) }body { actionsMenu() script(type = "module") { unsafe { +"import { defineLt } from \"${LotekWebJar.REGISTER}\"; defineLt()" } }}The panel’s id comes from the template, and the button points to it. Make it from a key in the data, never from the loop index or a counter, so it is unique on the page and the same on every render: a morph recognises an element by its id. See Ids that survive an update.
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">Mark LDG-117 returned</button></li> <li><button class="lt-button" data-variant="ghost" type="button">Charge the late fee</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 rendered with a builder function, lt.popover() writes the wiring, the id, the class and popover. When it comes from a template without one, 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”
What a screen reader is given when the recipe above is used, step by step. It is checked through AT-SPI, the
interface Orca reads, in Chromium and Firefox, on every change, by check-accessibility-tree.ts, and
every change the check lists as a mutation must make it fail.
- Focus
#menu > button: focus on thebutton"Actions", expandable, not expanded. - Press Enter: focus on the
button"Actions", expanded; the change to expanded sent as an event. - Press Tab: focus on the
button"Mark LDG-117 returned". - Press Escape: focus on the
button"Actions", not expanded.
- 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.