# Overlay core

> Positioning, dismissal, focus-trap and roving-focus helpers for popovers, menus and dialogs.

- **Slug:** `overlay`
- **Kind:** atom · **Category:** Overlays
- **Source:** original to Viewership UI
- **Docs page:** https://viewership.ai/ui/components/overlay
- **All files in one bundle:** https://viewership.ai/ui/components/overlay.bundle.txt
- **Structured JSON:** https://viewership.ai/ui/components/overlay.json

## Install

1. Import the foundation tokens once (download: https://viewership.ai/ui/raw/foundation.css.txt):

```astro
---
// src/layouts/Layout.astro
import "../styles/foundation.css";
---
```

2. Copy this file into `src/components/viewership/overlay/`:

   - `overlay.ts` (raw: https://viewership.ai/ui/raw/overlay/overlay.ts.txt)

npm packages: none.

## Example

The gallery demo, verbatim. Use the import path from Usage instead of `./`.

```astro
---
/** Demo: one anchored panel, re-placed on each side. Scroll a trigger to the edge to see flip and shift. */
interface Props {
  variant?: string;
}
const { variant = "Placement" } = Astro.props;
const placements = ["top", "right", "bottom", "left"] as const;
---

<div class="vs-overlay-demo" data-variant={variant}>
  <div class="row">
    {placements.map((p) => (
      <button type="button" class="trigger" data-placement={p} aria-expanded="false">
        {p}
      </button>
    ))}
  </div>
  <div class="panel" popover="manual" role="dialog" aria-label="Draft note">
    <p class="title">Spring launch draft</p>
    <p class="body">Brand voice checked. Waiting on editor approval.</p>
  </div>
</div>

<script>
  import { computePosition, autoUpdate, onDismiss, type Placement } from "./overlay";

  document.querySelectorAll<HTMLElement>(".vs-overlay-demo:not([data-vs-ready])").forEach((root) => {
    root.dataset.vsReady = "";
    const panel = root.querySelector<HTMLElement>(".panel")!;
    let cleanup: Array<() => void> = [];
    let openFor: HTMLElement | null = null;

    const close = () => {
      cleanup.forEach((fn) => fn());
      cleanup = [];
      openFor?.setAttribute("aria-expanded", "false");
      openFor = null;
      panel.hidePopover();
    };

    root.querySelectorAll<HTMLElement>(".trigger").forEach((trigger) => {
      trigger.addEventListener("click", () => {
        const same = openFor === trigger;
        close();
        if (same) return;
        openFor = trigger;
        trigger.setAttribute("aria-expanded", "true");
        panel.showPopover();
        const placement = trigger.dataset.placement as Placement;
        cleanup.push(autoUpdate(trigger, panel, () => computePosition(trigger, panel, { placement, offset: 8 })));
        cleanup.push(onDismiss(panel, close, { inside: [trigger] }));
      });
    });
  });
</script>

<style>
  .row {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 8px;
  }
  .trigger {
    height: 30px;
    padding: 0 14px;
    border: none;
    border-radius: var(--vs-radius-pill);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-btn);
    color: var(--vs-ink);
    font: inherit;
    font-size: 13px;
    text-transform: capitalize;
    cursor: pointer;
  }
  .trigger[aria-expanded="true"] {
    background: var(--vs-hover);
  }
  .trigger:focus-visible {
    outline: none;
    box-shadow: var(--vs-focus-ring);
  }
  .panel {
    width: 220px;
    padding: 10px 12px;
    border: none;
    border-radius: var(--vs-radius-card);
    background: var(--vs-surface);
    box-shadow: var(--vs-shadow-overlay);
    color: var(--vs-ink);
    animation: vs-pop-in 160ms var(--vs-ease-out) both;
  }
  .title {
    margin: 0;
    font-size: 13px;
    font-weight: 500;
  }
  .body {
    margin: 2px 0 0;
    font-size: 12.5px;
    color: var(--vs-ink-2);
  }
</style>
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `computePosition(anchor, floating, opts)` | `function` |  | Places a fixed element beside its anchor with flip and shift. |
| `autoUpdate(anchor, floating, update)` | `function` |  | Re-runs `update` on scroll and resize. Returns cleanup. |
| `onDismiss(el, dismiss, opts)` | `function` |  | Escape, outside-pointer and focus-out dismissal. Returns cleanup. |
| `trapFocus(container, opts)` | `function` |  | Contains Tab focus. Release restores prior focus. |
| `rovingFocus(container, opts)` | `function` |  | Arrow/Home/End navigation with typeahead. |
| `uid(prefix)` | `function` |  | Unique id for ARIA wiring. |

## Source

### `src/components/viewership/overlay/overlay.ts`

```ts
/**
 * overlay: shared behaviour for floating and modal components (popover,
 * tooltip, select, combobox, menus, dialogs). Framework-free:
 *   - computePosition / autoUpdate   anchor positioning with flip + shift
 *   - onDismiss                      Escape + outside-pointer dismissal
 *   - trapFocus                      modal focus containment + restore
 *   - rovingFocus                    arrow-key navigation + typeahead
 *   - uid                            stable ids for aria wiring
 *
 * Put floating elements in the top layer (Popover API or modal <dialog>) so
 * ancestors cannot clip them. Positions are viewport coordinates (fixed).
 */

export type Side = "top" | "right" | "bottom" | "left";
export type Align = "start" | "center" | "end";
export type Placement = Side | `${Side}-${Align}`;

export interface PositionOptions {
  placement?: Placement;
  /** Gap between anchor and floating element, px. */
  offset?: number;
  /** Minimum distance from the viewport edge, px. */
  padding?: number;
  /** Flip to the opposite side when the preferred side does not fit. */
  flip?: boolean;
  /** Slide along the cross axis to stay inside the viewport. */
  shift?: boolean;
  /** Match the anchor's width (select/combobox listboxes). */
  matchWidth?: boolean;
}

export interface PositionResult {
  x: number;
  y: number;
  side: Side;
  align: Align;
}

const OPPOSITE: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };

function split(p: Placement): [Side, Align] {
  const [side, align = "center"] = p.split("-") as [Side, Align?];
  return [side, align as Align];
}

function coords(a: DOMRect, f: { width: number; height: number }, side: Side, align: Align, offset: number) {
  const vertical = side === "top" || side === "bottom";
  let x = 0;
  let y = 0;
  if (vertical) {
    y = side === "bottom" ? a.bottom + offset : a.top - f.height - offset;
    x = align === "start" ? a.left : align === "end" ? a.right - f.width : a.left + (a.width - f.width) / 2;
  } else {
    x = side === "right" ? a.right + offset : a.left - f.width - offset;
    y = align === "start" ? a.top : align === "end" ? a.bottom - f.height : a.top + (a.height - f.height) / 2;
  }
  return { x, y };
}

function fits(side: Side, a: DOMRect, f: { width: number; height: number }, offset: number, pad: number) {
  const vw = window.innerWidth;
  const vh = window.innerHeight;
  switch (side) {
    case "bottom": return a.bottom + offset + f.height <= vh - pad;
    case "top": return a.top - offset - f.height >= pad;
    case "right": return a.right + offset + f.width <= vw - pad;
    case "left": return a.left - offset - f.width >= pad;
  }
}

/**
 * Positions `floating` next to `anchor` and writes `left/top` (fixed) plus
 * `data-side` / `data-align` (for direction-aware animations and arrows).
 */
export function computePosition(anchor: Element, floating: HTMLElement, opts: PositionOptions = {}): PositionResult {
  const { placement = "bottom", offset = 6, padding = 8, flip = true, shift = true, matchWidth = false } = opts;
  const a = anchor.getBoundingClientRect();

  floating.style.position = "fixed";
  floating.style.margin = "0";
  if (matchWidth) floating.style.minWidth = `${a.width}px`;
  const f = { width: floating.offsetWidth, height: floating.offsetHeight };

  let [side, align] = split(placement);
  if (flip && !fits(side, a, f, offset, padding) && fits(OPPOSITE[side], a, f, offset, padding)) {
    side = OPPOSITE[side];
  }
  let { x, y } = coords(a, f, side, align, offset);

  if (shift) {
    const maxX = window.innerWidth - f.width - padding;
    const maxY = window.innerHeight - f.height - padding;
    x = Math.min(Math.max(x, padding), Math.max(padding, maxX));
    y = Math.min(Math.max(y, padding), Math.max(padding, maxY));
  }

  floating.style.left = `${Math.round(x)}px`;
  floating.style.top = `${Math.round(y)}px`;
  floating.dataset.side = side;
  floating.dataset.align = align;
  // Transform origin points back at the anchor, for scale-in animations.
  const vertical = side === "top" || side === "bottom";
  const cross = align === "center" ? "center" : vertical ? (align === "start" ? "left" : "right") : align === "start" ? "top" : "bottom";
  floating.style.transformOrigin = `${OPPOSITE[side]} ${cross}`;
  return { x, y, side, align };
}

/**
 * Keeps a floating element positioned while open: on scroll (any ancestor),
 * resize, and size changes of either element. Returns a cleanup function.
 */
export function autoUpdate(anchor: Element, floating: HTMLElement, update: () => void): () => void {
  let frame = 0;
  const schedule = () => {
    cancelAnimationFrame(frame);
    frame = requestAnimationFrame(update);
  };
  window.addEventListener("scroll", schedule, { capture: true, passive: true });
  window.addEventListener("resize", schedule, { passive: true });
  const ro = new ResizeObserver(schedule);
  ro.observe(anchor);
  ro.observe(floating);
  update();
  return () => {
    cancelAnimationFrame(frame);
    window.removeEventListener("scroll", schedule, { capture: true });
    window.removeEventListener("resize", schedule);
    ro.disconnect();
  };
}

export interface DismissOptions {
  /** Elements that count as "inside" (e.g. the trigger). */
  inside?: Array<Element | null | undefined>;
  escape?: boolean;
  outsidePointer?: boolean;
  /** Close when focus moves outside (non-modal popovers). */
  focusOut?: boolean;
}

/**
 * Calls `dismiss(reason)` on Escape or a pointerdown outside `el` and
 * `inside`. Returns a cleanup function.
 */
export function onDismiss(
  el: HTMLElement,
  dismiss: (reason: "escape" | "outside" | "focusout") => void,
  { inside = [], escape = true, outsidePointer = true, focusOut = false }: DismissOptions = {},
): () => void {
  const isInside = (t: EventTarget | null) =>
    t instanceof Node && (el.contains(t) || inside.some((i) => i && i.contains(t)));

  const onKey = (e: KeyboardEvent) => {
    if (escape && e.key === "Escape" && !e.defaultPrevented) {
      e.preventDefault();
      dismiss("escape");
    }
  };
  const onPointer = (e: PointerEvent) => {
    if (outsidePointer && !isInside(e.target)) dismiss("outside");
  };
  const onFocus = (e: FocusEvent) => {
    if (focusOut && !isInside(e.target)) dismiss("focusout");
  };
  document.addEventListener("keydown", onKey);
  document.addEventListener("pointerdown", onPointer, true);
  document.addEventListener("focusin", onFocus);
  return () => {
    document.removeEventListener("keydown", onKey);
    document.removeEventListener("pointerdown", onPointer, true);
    document.removeEventListener("focusin", onFocus);
  };
}

const FOCUSABLE =
  'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';

function focusables(container: ParentNode): HTMLElement[] {
  return [...container.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
    (el) => !el.closest("[inert]") && el.getClientRects().length > 0,
  );
}

/**
 * Contains Tab focus within `container` (for modal surfaces not using a
 * modal <dialog>, which traps natively). Returns a release function that
 * restores focus to whatever was focused before.
 */
export function trapFocus(container: HTMLElement, { initial }: { initial?: HTMLElement | null } = {}): () => void {
  const previous = document.activeElement as HTMLElement | null;
  (initial ?? focusables(container)[0] ?? container).focus({ preventScroll: true });

  const onKey = (e: KeyboardEvent) => {
    if (e.key !== "Tab") return;
    const items = focusables(container);
    if (!items.length) {
      e.preventDefault();
      return;
    }
    const first = items[0];
    const last = items[items.length - 1];
    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  };
  container.addEventListener("keydown", onKey);
  return () => {
    container.removeEventListener("keydown", onKey);
    previous?.focus?.({ preventScroll: true });
  };
}

export interface RovingOptions {
  /** Selector for navigable items within the container. */
  items: string;
  orientation?: "vertical" | "horizontal" | "both";
  loop?: boolean;
  /** Type characters to jump to the item whose text starts with them. */
  typeahead?: boolean;
  /**
   * "focus": move real focus with a roving tabindex (menus, toolbars, tabs).
   * "active": keep focus on the container/input and move aria-activedescendant
   * (listboxes driven from a combobox input).
   */
  mode?: "focus" | "active";
  /** Element that receives aria-activedescendant in "active" mode. */
  controller?: HTMLElement;
  onMove?: (item: HTMLElement) => void;
}

/**
 * Arrow-key / Home / End navigation with optional typeahead.
 * Disabled items (`aria-disabled="true"` or `disabled`) are skipped.
 * Returns a handle to move programmatically, plus cleanup.
 */
export function rovingFocus(container: HTMLElement, opts: RovingOptions) {
  const { items: selector, orientation = "vertical", loop = true, typeahead = true, mode = "focus", controller, onMove } = opts;
  const keyTarget = controller ?? container;
  let buffer = "";
  let bufferTimer = 0;

  const list = () =>
    [...container.querySelectorAll<HTMLElement>(selector)].filter(
      (el) => el.getAttribute("aria-disabled") !== "true" && !(el as HTMLButtonElement).disabled && !el.hidden,
    );

  const current = () => {
    const items = list();
    if (mode === "active") {
      const id = (controller ?? container).getAttribute("aria-activedescendant");
      return items.findIndex((el) => el.id === id);
    }
    return items.findIndex((el) => el === document.activeElement || el.contains(document.activeElement));
  };

  function move(target: HTMLElement | null) {
    if (!target) return;
    if (mode === "focus") {
      list().forEach((el) => (el.tabIndex = el === target ? 0 : -1));
      target.focus({ preventScroll: false });
    } else {
      list().forEach((el) => el.removeAttribute("data-active"));
      target.dataset.active = "";
      if (!target.id) target.id = uid("vs-opt");
      (controller ?? container).setAttribute("aria-activedescendant", target.id);
      target.scrollIntoView({ block: "nearest" });
    }
    onMove?.(target);
  }

  function step(delta: number) {
    const items = list();
    if (!items.length) return;
    const i = current();
    let next = i === -1 ? (delta > 0 ? 0 : items.length - 1) : i + delta;
    if (loop) next = (next + items.length) % items.length;
    else next = Math.max(0, Math.min(items.length - 1, next));
    move(items[next]);
  }

  const prevKeys = orientation === "horizontal" ? ["ArrowLeft"] : orientation === "vertical" ? ["ArrowUp"] : ["ArrowUp", "ArrowLeft"];
  const nextKeys = orientation === "horizontal" ? ["ArrowRight"] : orientation === "vertical" ? ["ArrowDown"] : ["ArrowDown", "ArrowRight"];

  const onKey = (e: KeyboardEvent) => {
    if (nextKeys.includes(e.key)) {
      e.preventDefault();
      step(1);
    } else if (prevKeys.includes(e.key)) {
      e.preventDefault();
      step(-1);
    } else if (e.key === "Home") {
      e.preventDefault();
      move(list()[0] ?? null);
    } else if (e.key === "End") {
      e.preventDefault();
      const items = list();
      move(items[items.length - 1] ?? null);
    } else if (typeahead && mode === "focus" && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && e.key !== " ") {
      buffer += e.key.toLowerCase();
      window.clearTimeout(bufferTimer);
      bufferTimer = window.setTimeout(() => (buffer = ""), 500);
      const items = list();
      const start = Math.max(0, current() + (buffer.length === 1 ? 1 : 0));
      const ordered = [...items.slice(start), ...items.slice(0, start)];
      const hit = ordered.find((el) => (el.textContent ?? "").trim().toLowerCase().startsWith(buffer));
      if (hit) move(hit);
    }
  };

  keyTarget.addEventListener("keydown", onKey);
  if (mode === "focus") {
    const items = list();
    items.forEach((el, i) => (el.tabIndex = i === 0 ? 0 : -1));
  }

  return {
    move,
    first: () => move(list()[0] ?? null),
    last: () => {
      const items = list();
      move(items[items.length - 1] ?? null);
    },
    step,
    destroy: () => {
      keyTarget.removeEventListener("keydown", onKey);
      window.clearTimeout(bufferTimer);
    },
  };
}

let counter = 0;
/** Unique, stable-within-page id for aria wiring. */
export function uid(prefix = "at"): string {
  counter += 1;
  return `${prefix}-${counter.toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
}
```
