# Popover

> Panel that hangs below a button and closes itself. Frame component.

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.

> **Two paths to the same markup**
>
> When the markup is produced with JavaScript, `lt.popover()` gives the wiring between button and panel, the id and the `popover` attribute, ready in the HTML. When it is produced without JavaScript, a button and a panel with the class `lt-popover` and an id are enough: the component sets `popover="manual"` and writes `aria-controls` on the button. The template writes the id, since `aria-controls` points to it. The component never creates one: a panel with an id the server did not send was replaced on the next update, along with focus. If the id is missing, the popover works, but the button is not wired to the panel, and the component, `lotek check` and the editors warn about it. The position, Escape and click outside come from the component in both cases. See [Markup and behaviour](/markup-and-behaviour.md#builder-function-or-web-component).

```ts
// 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()
```

## How to use it

**Plain HTML**

```html
<!-- 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 -->
```

```html
<!-- 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>
```

**React**

```ts
// src/lotek.d.ts, gives <lt-popover> types in JSX
import "@lo-tek/interfaces/react-jsx"
```

```tsx
// main.tsx, runs once when the app starts
import { defineLtPopover } from "@lo-tek/interfaces/popover"
import "@lo-tek/interfaces/popover.css"
import "@lo-tek/interfaces/button.css"


defineLtPopover()
```

```tsx
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.

**Astro**

```astro
---
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**

```html
<!-- 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.

```ts
const box = lt.popover({ id: "actions", placement: "bottom-end" })
```

```html
<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](/markup-and-behaviour.md).

## 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

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

```js
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

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](/customising.md) for how the variables and the layer work.

## 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](/components/dialog.md).
- 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.
