Skip to main content

assets/js/exit.js

/**
 * Exit animation. Triggered by the `shift:exit` event dispatched from the
 * component's `phx-remove`. LiveView keeps the node alive for the duration
 * of the exit before actually removing it.
 *
 * Two subtle things this module handles:
 *
 *   1. Cascading exits: morphdom shuffles `phx-remove` nodes to the end of
 *      the parent's children to fit the new tree, which jumbles their
 *      visual order. We restore each transitioning element to its
 *      last-known slot so a second dismissal mid-first-exit doesn't pile up
 *      out of order.
 *
 *   2. Pin-out-of-flow decision: taking the exit out of flex/grid/block
 *      flow keeps the parent from briefly being one item taller than it
 *      should be (the "phantom slot" bug on stacks like toasts and sliding
 *      windows). But pinning a lone exit shrinks the parent immediately,
 *      snapping whatever sits below. So we only pin when there's a sibling
 *      ENTERING in the same patch (popLayout-style replacement). We detect
 *      that without waiting for MutationObserver: morphdom inserts new
 *      nodes before phx-remove fires, so a sibling with a `data-shift`
 *      attribute that we've never SEEN before is a pending enter.
 */

import { SEEN, TRACKED, RUNTIME, readSpec } from "./state.js";
import { measure } from "./measure.js";
import { REST, fillCollapseProps, buildKeyframes } from "./keyframes.js";

export function exit(el) {
  /** Skip exits during a `live_redirect` — see RUNTIME.navigating. */
  if (RUNTIME.navigating) return;

  const spec = readSpec(el);
  if (!spec) return;

  const disabled = spec.disable || [];
  const wantFade = !disabled.includes("fade");

  const exitMap = spec.exit ? { ...spec.exit } : {};
  /** Default fade-out — opacity is the universal "departure" animation. */
  if (wantFade && !("opacity" in exitMap)) exitMap.opacity = 0;
  if (Object.keys(exitMap).length === 0) return;

  const from = {};

  for (const key of Object.keys(exitMap)) {
    if (key === "height") from[key] = el.offsetHeight;
    else if (key === "width") from[key] = el.offsetWidth;
    else if (key in REST) from[key] = REST[key];
    else from[key] = 0;
  }

  const hasSize = "height" in exitMap || "width" in exitMap;
  if (hasSize) {
    from.overflow = "hidden";
    exitMap.overflow = "hidden";
    /**
     * Collapse padding/margin/border on the same axis so the box can fully
     * shrink past its padding floor — otherwise the exit visually plateaus.
     */
    if ("height" in exitMap) fillCollapseProps(exitMap, from, el, "height");
    if ("width" in exitMap) fillCollapseProps(exitMap, from, el, "width");
  }

  /**
   * Mark as transitioning so relayout skips us (the WAAPI animation will
   * transiently clamp our offsetHeight, which would look like a real layout
   * change and trigger a competing FLIP).
   */
  el.__shiftTransitioning = true;

  /**
   * DOM-slot restoration always runs (cascading exits stay in order even
   * when no pin is applied). Pin only when:
   *   - exit isn't a size-collapse (an accordion explicitly wants its
   *     parent to shrink with it; pinning would freeze the parent at full
   *     size and the panel would visibly shrink in mid-air), AND
   *   - the parent has a SIBLING ENTERING in the same patch (popLayout-
   *     style replacement: sliding window, toast queue swap). Otherwise
   *     the exit is the only thing changing the layout and pinning would
   *     shrink the parent immediately, snapping whatever sits below.
   */
  restoreCascadingExitSlots();
  const parent = el.parentElement;
  const pendingSibling =
    parent &&
    Array.from(parent.children).some(
      (c) => c !== el && c.hasAttribute("data-shift") && !SEEN.has(c),
    );
  const recentEnter =
    parent && performance.now() - (parent.__shiftLastEnterMs || 0) < 100;
  if (!hasSize && (pendingSibling || recentEnter)) applyExitPin(el);

  const { keyframes, options } = buildKeyframes(
    from,
    exitMap,
    spec.transition,
    "exit",
  );
  el.animate(keyframes, options);

  /**
   * `display: table-row` ignores CSS `height` — the row's height is derived
   * from its tallest cell. We can't switch the row to `display: block`
   * without breaking column alignment, so we collapse each cell from the
   * inside: padding to 0 (shrinks the cell's box) and font-size +
   * line-height to 0 (collapses the inline content height). With both, the
   * cell — and therefore the row — can actually reach 0 height while the
   * table layout stays intact.
   */
  if (
    hasSize &&
    "height" in exitMap &&
    getComputedStyle(el).display === "table-row"
  ) {
    for (const cell of el.children) {
      if (cell.tagName !== "TD" && cell.tagName !== "TH") continue;
      const ccs = getComputedStyle(cell);
      const cellFrom = {
        paddingTop: parseFloat(ccs.paddingTop) || 0,
        paddingBottom: parseFloat(ccs.paddingBottom) || 0,
        fontSize: parseFloat(ccs.fontSize) || 0,
        lineHeight:
          ccs.lineHeight === "normal"
            ? (parseFloat(ccs.fontSize) || 0) * 1.2
            : parseFloat(ccs.lineHeight) || 0,
        overflow: "hidden",
      };
      const cellTo = {
        paddingTop: 0,
        paddingBottom: 0,
        fontSize: 0,
        lineHeight: 0,
        overflow: "hidden",
      };
      const built = buildKeyframes(cellFrom, cellTo, spec.transition, "exit");
      cell.animate(built.keyframes, built.options);
    }
  }

  /**
   * Size-collapse exits cause siblings to reflow continuously while the box
   * shrinks (the box stays in flow, so the parent's layout updates each
   * frame). The MutationObserver doesn't fire during a WAAPI animation, so
   * sibling __shiftLayout caches stay frozen at their pre-collapse positions.
   *
   * By the time LiveView removes the element, the post-removal relayout
   * computes a delta against those stale caches and FLIPs the siblings —
   * visibly snapping them back to where they were and animating them up
   * again.
   *
   * Keep the sibling caches honest by remeasuring on every animation frame
   * while this size-collapsing exit is alive.
   */
  if (hasSize) {
    const exitingParent = el.parentElement;
    const tick = () => {
      if (!el.isConnected) {
        /**
         * Element was just removed. Null sibling caches so the post-removal
         * relayout has no prev to diff against — there's nothing to FLIP
         * because siblings already rode up via CSS reflow during the height
         * collapse. The very next relayout will repopulate the caches with
         * current positions and normal FLIP behavior resumes afterward.
         */
        if (exitingParent && exitingParent.isConnected) {
          clearChildLayouts(exitingParent);
        }
        return;
      }
      refreshChildLayouts(exitingParent);
      requestAnimationFrame(tick);
    };
    requestAnimationFrame(tick);
  }
}

function refreshChildLayouts(parent) {
  if (!parent) return;
  for (const child of parent.children) {
    if (!child.hasAttribute("data-shift")) continue;
    if (!child.isConnected) continue;
    if (child.__shiftTransitioning) continue;
    child.__shiftLayout = measure(child);
  }
}

function clearChildLayouts(parent) {
  if (!parent) return;
  for (const child of parent.children) {
    if (!child.hasAttribute("data-shift")) continue;
    if (child.__shiftTransitioning) continue;
    child.__shiftLayout = null;
  }
}

/**
 * Re-insert every currently-transitioning element into the DOM slot it was
 * last seen in. Called before pinning so cascading dismissals stay in the
 * order the user perceives.
 */
export function restoreCascadingExitSlots() {
  for (const node of TRACKED) {
    if (!node.__shiftTransitioning) continue;
    if (typeof node.__shiftSiblingIndex !== "number") continue;

    const p = node.parentElement;
    if (!p) continue;

    const currentIndex = [...p.children].indexOf(node);
    const target = node.__shiftSiblingIndex;

    if (currentIndex === -1 || currentIndex === target) continue;

    if (target >= p.children.length) {
      if (p.lastElementChild !== node) p.appendChild(node);
      continue;
    }

    const refNode = p.children[target];
    if (refNode !== node) p.insertBefore(node, refNode);
  }
}

/**
 * Lift the exiting element out of flex/grid/block flow. The cached
 * coordinates are body-relative (summed offsetTop chain), so `position:
 * fixed` minus scrollY puts it back at its pre-removal viewport spot.
 *
 * Caller decides whether pinning is appropriate (see the recentEnter
 * check above).
 */
export function applyExitPin(el) {
  const cached = el.__shiftLayout;
  if (!cached) return;

  el.style.position = "fixed";
  el.style.top = cached.top - (window.scrollY || 0) + "px";
  el.style.left = cached.left - (window.scrollX || 0) + "px";
  el.style.width = cached.width + "px";
  el.style.height = cached.height + "px";
  el.style.margin = "0";
}