<!-- GENERATED by scripts/build-llms.mjs from llms/overlays.md — do not edit this file. -->

# `lr-popover`

- **Import** `import '@aceshooting/lyra-ui/components/lr-popover.js';` (stable tag alias; registers the tag)
- **Class** `LyraPopover`, also available unregistered from `@aceshooting/lyra-ui/components/overlays/overlay/popover.class.js`
- **Family** `components/overlays/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.0.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
- **Deprecations** none
- **Optional peers** none
- **Themeable via** 9 parts, 11 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-popover`

A click-triggered, light-dismiss floating surface positioned with the shared Floating UI positioner.

An open lr-popover repositions when its effective host or inherited text direction changes,
preserving open state without emitting lifecycle events.

**First-interaction registration.** A performance-sensitive navigation shell can keep a native
`<details>` disclosure working before JavaScript, then load only the granular popover registration
when that fallback first opens. The interaction that starts an async import is not replayed after
the custom element upgrades, so finish the handoff explicitly with `show()`:

```html
<details id="account-fallback">
  <summary>Account</summary>
  <nav aria-label="Account"><a href="/profile">Profile</a></nav>
</details>
<lr-popover id="account-popover" popup-role="none" hidden>
  <button slot="trigger">Account</button>
  <nav aria-label="Account"><a href="/profile">Profile</a></nav>
</lr-popover>
<script type="module">
  const fallback = document.querySelector("#account-fallback");
  const popover = document.querySelector("#account-popover");
  let registration;
  fallback.addEventListener("toggle", async () => {
    if (!fallback.open || !popover.hidden) return;
    registration ??= import(
      "@aceshooting/lyra-ui/components/overlays/overlay/popover.js"
    ).catch((error) => {
      registration = undefined; // let a later interaction retry
      throw error;
    });
    await registration;
    await customElements.whenDefined("lr-popover");
    fallback.hidden = true;
    popover.hidden = false;
    await popover.show();
  });
</script>
```

This keeps the fallback's markup and semantics in the initial document. The registration, Lyra
base, nonmodal stack, and first-open positioning runtime remain outside that initial module graph.
If the import fails, leave the native disclosure visible and usable.

**Properties:**

- `open: boolean = false` (reflected) — assigning it runs the same `lr-show`/`lr-hide` lifecycle as
  `show()`/`hide()`, so the property, the reflected attribute and the two methods can never disagree
- `placement: Placement = 'top'` (reflected) — the full Floating UI vocabulary, mirrored
  under RTL
- `positioningStrategy: PlaceStrategy = 'fixed'` (attribute `positioning-strategy`, reflected) — CSS
  positioning scheme the popup is laid out with, `'absolute' | 'fixed'`. The one property
  `<lr-popover>`, `<lr-dropdown>`, `<lr-select>`, `<lr-tooltip>` and `<lr-color-picker>` all spell
  the same way; each keeps its own default, so setting nothing changes nothing. An unsupported value
  resolves back to that default. This property always reports the instance's own authored value (or
  its mirrored default) — see the cascading `--lr-positioning-strategy` custom property below for a
  theme-level way to change the *rendered* strategy of every instance that sets neither.
- `trigger: string = 'click'` — a _space-separated_ list of `click` (the shipped behaviour),
  `hover`, `focus` and `manual`, spelled exactly the way `<lr-tooltip>`'s `trigger` is, so
  `trigger="hover focus"` means the same thing on both. `LyraPopoverTrigger` is the type of one
  keyword. The two transient modes open after `showDelay`, close after `hideDelay` once the
  interaction ends, never move focus into the surface, and stay open while focus rests anywhere
  inside it. A click on the trigger pins a transient surface open; the next click releases the pin
  and closes it. `manual` refuses every interaction, leaves the surface to `show()`/`hide()`/`open`,
  and wins over any keyword beside it. Unrecognized tokens are dropped and the property reads back
  as the canonical list; unlike `<lr-tooltip>`, a list left with no recognized keyword resolves to
  `'click'` rather than to manual, so a typo can never strand a popover's content behind `show()`.
  Both the slotted trigger and a `for=`-resolved external trigger honour every keyword.
- `showDelay: number = 0` (attribute `show-delay`) — ms before a `hover`/`focus` interaction opens
  the popover.
- `hideDelay: number = 0` (attribute `hide-delay`) — ms before the interaction ending closes it; the
  grace period that lets a pointer cross the gap to the popup.
- `hoverBridge: boolean = false` (attribute `hover-bridge`, reflected) — clips an invisible
  `[part='hover-bridge']` quad across the `distance` gap while a `hover` popover is open, so a
  pointer travelling between trigger and popup never leaves both at once.
- `distance: number = 8` — anchor-offset distance in px (Floating UI's main-axis `offset()`). May
  legitimately be negative to overlap the trigger; a non-finite value falls back to the default.
- `skidding: number = 0` — offset _along_ the anchor's edge, in px (Floating UI's cross-axis
  offset). New in 8.0.0.
- `for: string = ''` (reflected) — id of an element resolved in this element's own root. It is the
  positioning source behind a direct `.anchor`; when it resolves to a live HTML element and no
  trigger is slotted, it also owns click and generated ARIA. A slotted trigger wins interaction/ARIA
  ownership even when positioning uses `for`, and a `showAt()` virtual anchor wins positioning while
  suppressing every DOM interaction owner.
  Assigning `null` is the mapped setter-only clearing spelling: it removes the attribute and the
  getter continues to return `''`
- `anchor: Element | null = null` (property only) — positioning-only direct anchor, taking priority
  over `for` and the interaction owner but never receiving click listeners or generated ARIA; a
  `showAt()` virtual anchor still wins
- `arrow: boolean = true` (reflected) — render an arrow pointing at the anchor; the true-default
  converter accepts `arrow="false"`
- `withoutArrow: boolean = false` (attribute `without-arrow`, reflected) — positive mapped spelling
  for suppressing the default arrow
- `arrowPlacement: 'anchor'|'start'|'end'|'center' = 'anchor'` (attribute `arrow-placement`) —
  `anchor` tracks the anchor's centre; `start`/`end` pin the arrow `arrow-padding` from one logical
  end of the edge (the two ends are the inline ones on a top/bottom placement, so they swap under
  RTL; on a left/right placement they are the block ends, which do not); `center` pins it to the
  middle of the edge regardless of where the anchor is
- `arrowPadding: number = 0` (attribute `arrow-padding`) — keeps the arrow this many px from the
  popup's corners
- `accessibleLabel: string = ''` (attribute **`aria-label`**) — names the popup. An authored host
  attribute wins by presence, including `aria-label=""`; only when it is absent does the property
  or localized "Popover" ("Menu" when `popupRole` is `menu`) fallback apply
- `popupRole: 'dialog'|'menu'|'none' = 'dialog'` (attribute `popup-role`). `none` (new in 11.0.0)
  renders **no** `role` and no generated `aria-label` on the popup surface, and leaves
  `aria-haspopup` off the trigger, so slotted content owns its own semantics and accessible name.
  Unsupported attribute values and untyped property writes normalize to `dialog` before any role
  or trigger ARIA is rendered.
  It exists for the WAI-ARIA **disclosure navigation** pattern: a flyout of links is not an
  application action menu (`menu` announces "menu, menu item" and expects `menuitem` children) and
  is not an interruptive surface (`dialog`). The `aria-expanded`/`aria-controls` wiring that pattern
  requires is unchanged, as are light dismiss, Escape, focus return, and positioning:

  ```html
  <lr-popover popup-role="none">
    <button slot="trigger">Products</button>
    <nav aria-label="Products">
      <ul><li><a href="/overview">Overview</a></li><li><a href="/pricing">Pricing</a></li></ul>
    </nav>
  </lr-popover>
  ```

  Note there is deliberately no `aria-haspopup="none"` — that is an invalid attribute value, not a
  neutral one, and axe reports it as a critical violation. `lr-dropdown` still pins `popupRole` to
  `menu`; the escape hatch lives on the general-purpose primitive.
- `disabled: boolean = false` (reflected, new in 10.0.0) — prevents opening the popover; pointer,
  keyboard, and programmatic `show()`/`open = true` are all refused while set. Becoming disabled also
  closes an already-open popover, and initial `disabled` plus `open` normalizes closed in either
  attribute order. `lr-dropdown` now inherits this from `lr-popover` rather than declaring its own;
  its consumer-facing behavior is unchanged

To preserve the previous Lyra-shaped defaults explicitly, use
`placement="bottom-start" distance="4" without-arrow`; origin-aware migration emits those tokens.

The slotted trigger receives `aria-haspopup`, `aria-expanded`, and `aria-controls`. With no slotted
trigger, a live HTML `for` target receives the identical ownership contract. A wrapper/custom
trigger's composed descendant that actually receives focus receives the same semantics and becomes
the focus-return target. The component supplies the real popup to the shared relationship owner;
because current browsers reject a light-DOM reference into a private shadow tree, that inward edge
is exposed as the public `lr-popover` host. Target insertion, removal, replacement, `id` changes,
and late custom-element upgrade are tracked live. Authored relationship tokens compose, generated
whole-value attributes stay authoritative while owned, and exact late-authored baselines return
when ownership moves or disconnects.
An enabled, non-inert light-DOM descendant with `data-popover="close"` requests its closest owning
popover to close when activated. Disabled/`aria-disabled` actions are inert, and a nested popover
consumes its own action so the same click never closes an ancestor.
**Methods:** `show(): Promise<void>` opens the popover programmatically — identical to
`el.open = true`, including the veto point — and resolves after `lr-after-show`. A no-op or vetoed
transition returns an already-resolved promise.
`showAt(rect: { x, y, width?, height?, contextElement? }, options?: { returnFocusTo?:
HTMLElement })` opens the popover anchored to an arbitrary rectangle instead of any DOM anchor —
for a graph node, a canvas pixel, a chart datum, or any other non-DOM location
(`width`/`height` default to `0`, a point). Escape and light-dismiss return focus to
`options.returnFocusTo` when supplied, or skip focus-return entirely otherwise, since a virtual
anchor has no `.focus()`. The virtual anchor has no DOM node of its own for `autoUpdate()` to
track ancestor scroll/resize against — pass `rect.contextElement` (a real, still-connected element
near the virtual point) when one is available to give it something to observe; otherwise, or when
the anchor point moves on its own (e.g. a graph pan/zoom tick), re-call `showAt()` with fresh
coordinates to re-anchor — the popover stays open across such a call. A popover that never calls
`showAt()` behaves exactly as before. Non-finite coordinates or dimensions are a no-op and leave
the current open/anchor state unchanged. While virtual anchoring is active, no slotted/`for` DOM
element owns click or generated ARIA.
`hide(options?: { focusTrigger?: boolean }): Promise<void>` programmatically closes the popover and
resolves after `lr-after-hide`; pass
`{ focusTrigger: false }` to opt out of focus restoration. By default, `hide()`, Escape, light
dismiss, and a bare `el.open = false` all return focus to the slotted/`for` owner's real composed
focus target, or to a virtual anchor's explicit `returnFocusTo`; a virtual anchor with no return
target closes without moving focus. No-op when already closed.
**Events:** `lr-show` (cancelable), `lr-after-show`, `lr-hide` (cancelable), `lr-after-hide` — none
carries a detail, and the two `lr-after-*` events are never cancelable. Neither pair fires for
markup that renders open from the start, nor when only `placement`/`distance` change on an
already-open popover.

Removing the sole connected direct anchor or sole interaction anchor from an open popover is
structural teardown: it force-closes even if an `lr-hide` listener would veto an ordinary close. If
a live slotted/`for` positioning fallback remains, the popover rebinds to it and stays open instead.

Public DOM-anchored `lr-popover` instances form a same-root singleton. A later ordinary `show()`
first requests the existing peer's cancelable close and remains closed if that peer vetoes. Initial
open markup stays lifecycle-silent: after the hydration-safe first-render boundary, the
later-connected instance wins and the earlier peer closes structurally without a veto or lifecycle
event. `lr-dropdown`, `showAt()` virtual surfaces, and popovers in separate document/shadow roots
remain independent. Re-entering the same `show()` or `hide()` request from its own before-event
coalesces onto one transition promise and emits the lifecycle once.

**Breaking in 8.0.0:** `lr-show`/`lr-hide` now fire _before_ the state changes and are cancelable —
`preventDefault()` on `lr-show` leaves the popover closed for the trigger click, `show()` and
`open = true` alike, and on `lr-hide` keeps it open for every dismissal path (Escape, light dismiss,
`hide()`, `open = false`). Reading `el.open` inside such a handler therefore returns the _old_
value; in 7.x these events fired after the fact and were purely informational. That is exactly the
timing `wa-show`/`wa-hide` always had, so the `wa-*` → `lr-*` migration table's "mechanical rename"
promise now holds for these names too — which also means 7.x Lyra code that read `el.open` in the
handler was relying on the _opposite_ polarity and must be re-read. `lr-after-show`/`lr-after-hide`
are new in 8.0.0 and settle after the public `popover.show` / `popover.hide` registry animation.
Per-element overrides win over page defaults; keyframes-only overrides retain the popup's
`--show-duration` / `--hide-duration` and shared easing. Reduced motion flattens timing to zero, and
a `null` registration skips interpolation, but neither path skips the after-event or its
method-promise settlement.

**Slots:** `trigger` (the interactive element that toggles the popover), default (popover content;
an enabled, non-inert descendant with `data-popover="close"` closes its nearest owning popover).

**CSS parts:** `trigger`; `popup dialog popup__popup`; `content body`;
`arrow popup__arrow` (rendered unless suppressed); and `hover-bridge` (the invisible quad, rendered
only while a `hover` popover with `hover-bridge` set is open). Names grouped together are aliases on
the same node. The arrow's part attribute also carries the **resolved side** as a second token — `arrow-top`,
`arrow-bottom`, `arrow-left`, `arrow-right` — so `::part(arrow arrow-top)` styles one side.
`::part(arrow)[data-side]` and `::part(arrow) .inner` are invalid selectors that silently never
match; the state is in the part name.

**Themeable custom properties:** mapped `--max-width`, `--arrow-size`, `--show-duration`, and
`--hide-duration`, with retained `--lr-overlay-max-inline-size` and `--lr-overlay-arrow-size`
fallbacks. Arrow size is half the square's width. Rendering the arrow switches `[part~="popup"]` to
`overflow: visible` so it is not clipped, moving the scroll container onto `[part~="content"]`.

The popup and its arrow paint from the **shared overlay-surface family** (16.0.0):
`--lr-overlay-surface` (default `var(--lr-color-surface-overlay)`), `--lr-overlay-border` (default
`var(--lr-color-border)`), `--lr-overlay-radius` (default `var(--lr-radius)`) and
`--lr-overlay-shadow-anchored` (default `var(--lr-shadow-m)`). The arrow takes the fill and the
edge but never the radius — its corners are already cut by its clip path. None of the four is
declared on `:host`, so a single declaration on `:root` retints every floating surface in the
application, and the same declaration on one component's own ancestor scopes the retint to that
subtree. `--lr-overlay-shadow-anchored` is deliberately a different name from the modal tier
`--lr-overlay-shadow-modal` that `lr-dialog`/`lr-drawer` read, so raising popups never raises
dialogs.

**`--lr-positioning-strategy` (16.0.0)** — a cascading `absolute`/`fixed` override for
`positioningStrategy`, read from computed style each time the popup is (re)positioned (open, or a
placement/anchor change while open — never per animation frame). Setting nothing anywhere leaves
every default exactly as before. Precedence: an explicit `positioning-strategy`/`hoist` on the
instance always wins; otherwise this inherited custom property; otherwise the component's own
mirrored default. Because it is a plain cascading custom property, one declaration on `:root`, a
theme, or a single clipping ancestor (an `overflow: hidden` card or a scroller) changes every unset
overlay beneath it — no need to author `positioning-strategy`/`hoist` on each instance individually,
or to remember it on every new one:

```html
<lr-card style="--lr-positioning-strategy: fixed; overflow: hidden">
  <lr-dropdown>
    <button slot="trigger">Actions</button>
    <lr-dropdown-item>Rename</lr-dropdown-item>
  </lr-dropdown>
</lr-card>
```

`<lr-popover>`, `<lr-dropdown>`, `<lr-select>`, `<lr-tooltip>` and `<lr-color-picker>` all honor it.

```html
<lr-popover
  arrow
  arrow-placement="center"
  placement="bottom"
  distance="8"
  skidding="12"
>
  <button slot="trigger" type="button">Details</button>
  <p>Anchored content.</p>
</lr-popover>
<script type="module">
  import "@aceshooting/lyra-ui/components/overlays/overlay/popover.js";

  const popover = document.querySelector("lr-popover");
  let ready = false;
  popover.addEventListener("lr-show", (event) => {
    // vetoes the open; popover.open is still false inside this handler
    if (!ready) event.preventDefault();
  });
  popover.addEventListener("lr-after-show", () => console.log("fully open"));
</script>
```
