Skip to main content

assets/js/shift.js

/**
 * Shift — animations for Phoenix LiveView, that just work.
 *
 * One MutationObserver watches the whole document. Any element rendered with
 * a `data-shift` attribute (emitted by the `<.animated>` component) is
 * animated in when it appears, out when LiveView removes it (via a
 * `shift:exit` event), and automatically animates its mid-life changes —
 * position (FLIP) and size (height/width) — when those change.
 *
 * The inferred mid-life animations are on by default. Opt out per element
 * with `disable={[:position]}` / `disable={[:size]}` / `disable={[:position,
 * :size]}`.
 *
 * Transitions are tweens by default (duration + easing). Pass
 * `transition: %{type: :spring, ...}` and the runtime simulates a real
 * spring and bakes the motion into keyframes.
 *
 * This file is the public entry point. The work is split across:
 *   state.js      — SEEN / TRACKED / readSpec cache
 *   measure.js    — body-relative layout measurement
 *   spring.js     — pure numerical spring solver
 *   keyframes.js  — pure keyframe construction helpers
 *   enter.js      — enter animation + smart defaults
 *   exit.js       — exit animation + cascade restore + pin-out-of-flow
 *   relayout.js   — inferred FLIP / size animations
 */

import { enter } from "./enter.js";
import { exit } from "./exit.js";
import { relayout } from "./relayout.js";
import { TRACKED, RUNTIME } from "./state.js";
import { measure } from "./measure.js";

function scan(node) {
  if (node.nodeType !== Node.ELEMENT_NODE) return;
  if (node.matches("[data-shift]")) {
    enter(node);
    observeVisibility(node);
  }
  node.querySelectorAll("[data-shift]").forEach((el) => {
    enter(el);
    observeVisibility(el);
  });
}

/**
 * Coalesce a burst of MutationObserver callbacks into a single relayout per
 * frame.
 *
 * LiveView patches the DOM in bursts — a single user interaction can produce
 * dozens of MutationRecords (text-node updates, attribute flips, child
 * re-insertions) within a few ms. Running relayout per record would mean
 * dozens of full passes over every animated element on the page, each one
 * forcing a layout read. Coalescing to one pass per frame means the
 * FLIP/size measurements all observe the same post-burst layout, and the
 * heavy work runs once.
 */
let relayoutScheduled = false;
function scheduleRelayout() {
  if (relayoutScheduled) return;
  relayoutScheduled = true;
  requestAnimationFrame(() => {
    relayoutScheduled = false;
    relayout();
  });
}

/**
 * Visibility observer — seeds `__shiftLayout` when a tracked element
 * transitions from hidden (zero-area / detached layout) to visible.
 *
 * The first measurement at enter time is null for elements mounted inside
 * a `display: none` ancestor. Without this, the cache stays empty until the
 * first DOM mutation while the element is visible — but by then the move
 * has already happened, so the FLIP has no pre-move position to anchor to
 * and the element snaps into place without animation.
 */
let visibilityObserver;

function observeVisibility(el) {
  if (visibilityObserver) visibilityObserver.observe(el);
}

/**
 * Wire the runtime up to the page. Call once on page load, after the DOM is
 * parsed (e.g. from your app.js after liveSocket.connect()).
 */
export function init() {
  visibilityObserver = new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        const el = entry.target;
        if (!entry.isIntersecting) continue;
        if (el.__shiftLayout) continue;
        const layout = measure(el);
        if (layout) el.__shiftLayout = layout;
      }
    },
    { threshold: 0 },
  );

  /** Elements present in the initial server render. */
  document.querySelectorAll("[data-shift]").forEach((el) => {
    enter(el);
    observeVisibility(el);
  });

  /**
   * Elements LiveView patches in later, plus inferred animations for
   * anything whose position or size changed.
   */
  new MutationObserver((mutations) => {
    for (const mutation of mutations) {
      for (const node of mutation.addedNodes) scan(node);
    }
    scheduleRelayout();
  }).observe(document.body, { childList: true, subtree: true });

  /** Exit animations, dispatched by the component's `phx-remove`. */
  document.addEventListener("shift:exit", (event) => exit(event.target));

  /**
   * On a `live_redirect`, LiveView waits for every outgoing `phx-remove`
   * exit timer to drain before showing the new page, so the longest exit
   * delays the whole navigation.
   *
   * Strip `phx-remove` off the outgoing animated nodes here and LiveView
   * registers no exit timers and swaps immediately. Sticky nodes survive
   * the navigation, so leave theirs intact (LiveView already excludes them
   * from the removal set). Mid-page exits run through patches (kind patch),
   * not redirects, so they keep animating normally.
   */
  window.addEventListener("phx:page-loading-start", (event) => {
    if (event.detail?.kind !== "redirect") return;

    RUNTIME.navigating = true;

    for (const el of document.querySelectorAll("[data-shift][phx-remove]")) {
      if (el.closest("[phx-sticky]")) continue;
      el.removeAttribute("phx-remove");
    }
  });
  window.addEventListener("phx:page-loading-stop", () => {
    RUNTIME.navigating = false;
  });

  /**
   * Window resize reflows the page without firing any DOM mutations, so the
   * MutationObserver never wakes up and every tracked element's cached
   * `__shiftLayout` goes stale. The next real layout change would then FLIP
   * against the pre-resize positions, animating the resize delta. Refresh
   * every cache on resize (rAF-coalesced for noisy resize streams).
   */
  let resizeScheduled = false;
  window.addEventListener("resize", () => {
    if (resizeScheduled) return;
    resizeScheduled = true;
    requestAnimationFrame(() => {
      resizeScheduled = false;
      for (const el of TRACKED) {
        if (!el.isConnected) continue;
        if (el.__shiftTransitioning) continue;
        el.__shiftLayout = measure(el);
      }
    });
  });
}