Download
132 lines 4 KiBJavaScriptModified 2026-08-01 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
// Pop-down popup panels (sliders, menus, calendar). One module owns the
// whole popup lifecycle: panel chrome, positioning, animation, and
// dismissal (click-outside + pointer-leave timer). The popup space itself
// — at-most-one-open and the backend X11 shape — is claimed from the
// Coordinator, which also keeps the shape synced when content resizes.
// Callers supply only their body via render().
//
//   const popup = createPopup({ anchor, className, render });
//   popup.toggle();               // args are forwarded to render()

import { claim, release } from "./coordinator.js";
import { centerUnder, animateIn, animateOut } from "./panel.js";

const CSS = `
  .dpanel {
    position: fixed;
    top: calc(var(--bar-height) + 8px);
    background: var(--bar-bg);
    border: 1px solid rgba(255, 255, 255, 0.09);
    border-radius: 14px;
    box-shadow: 0 10px 28px rgba(0, 0, 0, 0.5);
    font-family: var(--font);
    z-index: 10;
  }
  /* Shaped (compositor-less) windows: no soft shadow, flush under the bar,
     and only the bottom corners rounded — mirrored by the X11 shape the
     backend builds (radius must match set_popup_open's). */
  :root.no-compositor .dpanel {
    border-radius: 0 0 14px 14px;
    box-shadow: none;
    top: var(--bar-height);
  }
`;

let stylesInjected = false;

// holdOpen (optional): while it returns true, the pointer-leave timer
// re-arms instead of closing (e.g. mid-drag on a slider).
// place (optional): positions the panel, replacing the default
// centered-under-the-anchor placement; the Coordinator re-runs it
// whenever the content resizes.
// onClose (optional): runs whenever the popup closes, by any path —
// for callers whose state must outlive a single open/close cycle.
export function createPopup({
  anchor,
  className,
  render,
  holdOpen,
  leaveDelay = 900,
  place,
  onClose,
}) {
  if (!stylesInjected) {
    stylesInjected = true;
    const style = document.createElement("style");
    style.dataset.lib = "popup";
    style.textContent = CSS;
    document.head.appendChild(style);
  }

  let el = null;
  let spaceClaim = null;

  // Close when the pointer wanders away from both the popup and its anchor.
  // This is the main close path without a compositor, where clicks outside
  // the shaped window go to other applications and never reach us.
  let leaveTimer = null;
  const disarm = () => {
    if (leaveTimer) clearTimeout(leaveTimer);
    leaveTimer = null;
  };
  const arm = () => {
    disarm();
    leaveTimer = setTimeout(() => {
      if (holdOpen?.()) arm();
      else close();
    }, leaveDelay);
  };

  function onDocDown(e) {
    if (!el.contains(e.target) && !anchor.contains(e.target)) close();
  }

  function open(...args) {
    if (el) return;

    el = document.createElement("div");
    el.className = `dpanel ${className}`;
    render(el, ...args);
    document.body.appendChild(el);
    animateIn(el);

    // A "popup" claim always wins: it evicts any open popup or tooltip.
    spaceClaim = claim({
      el,
      kind: "popup",
      place: (target) => {
        if (place) place(target);
        else if (anchor.isConnected) centerUnder(target, anchor);
      },
      onEvict: close,
    });

    document.addEventListener("pointerdown", onDocDown, true);
    el.addEventListener("pointerenter", disarm);
    el.addEventListener("pointerleave", arm);
    anchor.addEventListener("pointerenter", disarm);
    anchor.addEventListener("pointerleave", arm);
  }

  function close() {
    if (!el) return;
    disarm();
    document.removeEventListener("pointerdown", onDocDown, true);
    anchor.removeEventListener("pointerenter", disarm);
    anchor.removeEventListener("pointerleave", arm);
    const closingEl = el;
    el = null;
    const c = spaceClaim;
    spaceClaim = null;
    animateOut(closingEl);
    release(c); // no-op if this popup was evicted by a newer claim
    onClose?.();
  }

  return {
    open,
    close,
    toggle: (...args) => (el ? close() : open(...args)),
    isOpen: () => el !== null,
  };
}