Popover

Click a trigger to toggle its popover. Click outside or press Esc to close.

Basic

Placement: right

With footer actions


  
/*
 * Popover — overlay
 *
 * Click-triggered floating panel anchored to a trigger element.
 * Non-modal: clicks outside dismiss it but interactions behind the
 * popover remain available. Per the answered design question:
 * reuses runtime/position.ts (no external Floating UI dependency).
 *
 *   <button type="button" class="button -secondary"
 *           aria-haspopup="dialog" aria-expanded="false"
 *           aria-controls="pop-1">More</button>
 *
 *   <div class="popover" id="pop-1" hidden role="dialog" aria-labelledby="pop-1-title">
 *     <header class="popover__header">
 *       <h3 id="pop-1-title" class="popover__title">Quick actions</h3>
 *     </header>
 *     <div class="popover__body">…</div>
 *   </div>
 */

@layer malevich.components {
  .popover {
    position: fixed;
    z-index: 1000;
    max-inline-size: var(--popover-max-width);

    background-color: var(--color-surface-float);
    color: var(--color-ink-strong);
    border: var(--border-width-hairline) solid var(--color-border-default);
    border-radius: var(--radius-tooltip);
    box-shadow: var(--shadow-float);

    /* Hidden by default — runtime toggles via [hidden] attribute. */
    opacity: 0;
    transform: translateY(calc(-1 * var(--space-inset-element-s)));
    transition:
      opacity var(--motion-fast) var(--motion-easing-default),
      transform var(--motion-fast) var(--motion-easing-default);
  }

  .popover[data-state="open"] {
    opacity: 1;
    transform: translateY(0);
  }

  .popover[hidden] {
    display: none;
  }

  .popover__header {
    padding: var(--space-inset-block-s) var(--space-inset-block-m);
    border-block-end: var(--border-width-hairline) solid var(--color-border-muted);
  }

  .popover__title {
    margin: 0;
    font-family: var(--font-heading-subsection-family);
    font-size: var(--font-heading-subsection-size);
    font-weight: var(--font-heading-subsection-weight);
    line-height: var(--font-heading-subsection-line-height);
    letter-spacing: var(--font-heading-subsection-letter-spacing);
    color: var(--color-ink-strong);
  }

  .popover__body {
    padding: var(--space-inset-block-m);

    font-family: var(--font-body-support-family);
    font-size: var(--font-body-support-size);
    font-weight: var(--font-body-support-weight);
    line-height: var(--font-body-support-line-height);
    letter-spacing: var(--font-body-support-letter-spacing);
    color: var(--color-ink-regular);
  }

  .popover__footer {
    padding: var(--space-inset-block-s) var(--space-inset-block-m);
    border-block-start: var(--border-width-hairline) solid var(--color-border-muted);
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: var(--space-gap-elements-s);
  }

  @media (prefers-reduced-motion: reduce) {
    .popover {
      transform: none;
      transition: opacity var(--motion-fast) var(--motion-easing-default);
    }
    .popover[data-state="open"] {
      transform: none;
    }
  }
}
// Popover — runtime.
//
// Discovers triggers with aria-controls pointing at a .popover, toggles
// the popover on click, positions it via runtime/position.ts, and
// closes on Escape / outside-click. Non-modal: focus is not trapped;
// the trigger remains in the tab order.

import { position, type Placement } from "../../runtime/position.js";

const READY_ATTR = "data-malevich-ready";

export interface PopoverInitOptions {
  /** Preferred placement; flips if it doesn't fit. Default: bottom. */
  placement?: Placement;
  /** Distance from anchor in px. Default: 8. */
  gap?: number;
}

function getPlacement(trigger: HTMLElement, fallback: Placement): Placement {
  const raw = trigger.getAttribute("data-popover-placement");
  if (raw === "top" || raw === "right" || raw === "bottom" || raw === "left") {
    return raw;
  }
  return fallback;
}

export function initPopover(
  trigger: HTMLElement,
  options: PopoverInitOptions = {},
): void {
  if (trigger.getAttribute(READY_ATTR) === "true") return;

  const targetId = trigger.getAttribute("aria-controls");
  if (!targetId) return;

  const popover = document.getElementById(targetId);
  if (!popover) return;

  const placement = options.placement ?? "bottom";
  const gap = options.gap ?? 8;

  let isOpen = false;

  function show(): void {
    popover!.hidden = false;
    // Force layout before positioning so size is correct.
    void popover!.offsetWidth;
    const placed = position(
      popover!,
      trigger,
      getPlacement(trigger, placement),
      gap,
    );
    popover!.style.top = `${placed.top}px`;
    popover!.style.left = `${placed.left}px`;
    popover!.setAttribute("data-state", "open");
    popover!.setAttribute("data-placement", placed.placement);
    trigger.setAttribute("aria-expanded", "true");
    isOpen = true;
    document.addEventListener("click", onDocumentClick, true);
    document.addEventListener("keydown", onKeyDown);
    window.addEventListener("resize", reposition);
    window.addEventListener("scroll", reposition, true);
  }

  function reposition(): void {
    if (!isOpen) return;
    const placed = position(
      popover!,
      trigger,
      getPlacement(trigger, placement),
      gap,
    );
    popover!.style.top = `${placed.top}px`;
    popover!.style.left = `${placed.left}px`;
    popover!.setAttribute("data-placement", placed.placement);
  }

  function hide(): void {
    if (!isOpen) return;
    popover!.removeAttribute("data-state");
    popover!.hidden = true;
    trigger.setAttribute("aria-expanded", "false");
    isOpen = false;
    document.removeEventListener("click", onDocumentClick, true);
    document.removeEventListener("keydown", onKeyDown);
    window.removeEventListener("resize", reposition);
    window.removeEventListener("scroll", reposition, true);
  }

  function onTriggerClick(event: MouseEvent): void {
    event.stopPropagation();
    isOpen ? hide() : show();
  }

  function onDocumentClick(event: MouseEvent): void {
    const target = event.target as Node | null;
    if (!target) return;
    if (popover!.contains(target)) return;
    if (trigger.contains(target)) return;
    hide();
  }

  function onKeyDown(event: KeyboardEvent): void {
    if (event.key === "Escape") {
      hide();
      trigger.focus();
    }
  }

  // Initial ARIA state
  if (!trigger.hasAttribute("aria-expanded")) {
    trigger.setAttribute("aria-expanded", "false");
  }
  if (!popover.hasAttribute("role")) {
    popover.setAttribute("role", "dialog");
  }
  popover.hidden = true;

  trigger.addEventListener("click", onTriggerClick);
  trigger.setAttribute(READY_ATTR, "true");
}

export function initPopovers(root: ParentNode = document): void {
  for (const trigger of root.querySelectorAll<HTMLElement>(
    "[aria-haspopup='dialog'][aria-controls]",
  )) {
    initPopover(trigger);
  }
}
# Popover — AGENTS.md

> Auto-generated from `popover.docs.md` + the modifier applicability matrix.
> Edit those source files; this file regenerates on build.

## Identity

- **Component:** `popover`
- **Layer:** overlays
- **Status:** stable
- **Last updated:** 2026-05-19

## Summary

- Quick action menu attached to a button.

## Modifier applicability

Per ADR-0007. Modifiers attach via `data-{category}="value"` attributes.

| Category | Accepts |
|---|---|
| `background` | — |
| `surface` | `float` |
| `effect` | `ring` |
| `shader` | — |
| `rhythm` | `compact`, `regular` |
| `motion` | any value |
| `behavior` | `dismissible` |

## Anatomy

```html
<button type="button" class="button -secondary"
        aria-haspopup="dialog"
        aria-expanded="false"
        aria-controls="pop-1">
  More
</button>

<div class="popover" id="pop-1" role="dialog" aria-labelledby="pop-1-title">
  <header class="popover__header">
    <h3 id="pop-1-title" class="popover__title">Quick actions</h3>
  </header>
  <div class="popover__body">
    <button class="button -ghost">Duplicate</button>
    <button class="button -ghost">Archive</button>
    <button class="button -ghost -danger">Delete</button>
  </div>
</div>
```

The runtime (`initPopover`, auto-registered for triggers with
`aria-haspopup="dialog"` + `aria-controls`) handles:

- Click to toggle.
- Position via `runtime/position.ts` (auto-flip if doesn't fit).
- Click-outside / Escape closes.
- Reposition on scroll and resize.
- Sync `aria-expanded` on the trigger.

The popover element starts `hidden`; the runtime toggles `hidden`,
`data-state="open"`, and inline `top`/`left` styles. Authors do NOT
position the popover manually.

## Tokens consumed

- `--color-surface-float` — background
- `--color-ink-strong`, `--color-ink-regular`
- `--color-border-default`, `--color-border-muted`
- `--shadow-float` — elevation
- `--radius-tooltip` — corner radius
- `--space-inset-block-{s,m}`, `--space-inset-element-s`
- `--space-gap-elements-s`
- `--border-width-hairline`
- `--font-heading-subsection-*`, `--font-body-support-*`
- `--motion-fast`, `--motion-easing-default`

### Component-tier (defined inline)
- `--popover-max-width` — overridable max inline-size

## Accessibility contract

- The trigger carries `aria-haspopup="dialog"` and `aria-controls`
  pointing at the popover id. The runtime toggles `aria-expanded`.
- The popover element has `role="dialog"` (runtime adds it if
  missing) and should reference its label via `aria-labelledby`.
- The popover is **non-modal** — focus is not trapped. Users can
  Tab out of the popover; that's intentional. For modal cases use
  Dialog.
- Escape closes the popover and returns focus to the trigger.
- Background is not `inert`. Background interactions remain available.

## Guidance

### Do

- Use real `<button>` triggers — keyboard activation comes for free.
- Set `aria-haspopup="dialog"`, `aria-controls`, and let the runtime
  manage `aria-expanded`.
- Add `aria-labelledby` referencing the popover's title.

### Don't

- Don't position the popover manually with CSS. The runtime sets
  `top` / `left`.
- Don't use Popover for ephemeral hint text. Tooltip is the right
  primitive there.
- Don't trap focus inside the popover. If you need modal focus
  trapping, you actually want a Dialog.