Skip to main content

priv/static/assets/phoenix_live_gantt.js

/**
 * PhoenixLiveGantt JS Hooks
 *
 * Optional but recommended JS hooks for the PhoenixLiveGantt chart:
 *   - LgBarPopover    : click bar/label to open the detail popover,
 *                       fade non-tree tasks, slide bottom badges past
 *                       the popover, etc.
 *   - LgAutoScroll    : center the today marker horizontally when the
 *                       chart mounts, re-centre on `lg:scroll-today`.
 *
 * Usage in your app.js:
 *
 *   import "../../deps/phoenix_live_gantt/priv/static/assets/phoenix_live_gantt.js"
 *
 *   let liveSocket = new LiveSocket("/live", Socket, {
 *     hooks: { ...window.PhoenixLiveGanttHooks, ...myHooks }
 *   })
 */

(function () {
  "use strict";

  window.PhoenixLiveGanttHooks = window.PhoenixLiveGanttHooks || {};

  window.PhoenixLiveGanttHooks.LgAutoScroll = {
    mounted() {
      this._onScrollToday = () => this._scrollToToday(true);
      this.el.addEventListener("lg:scroll-today", this._onScrollToday);

      // Scroll back to the timeline start (leftmost column). Fired by
      // `PhoenixLiveGantt.scroll_to_start/2` — e.g. a "fit to project" button whose
      // refit may not include today, so scroll-to-today wouldn't fire. The
      // `_pendingScrollStart` flag makes the post-patch `updated()` honor this
      // even when the refit moves the today marker (which would otherwise
      // re-center on today and override us).
      this._onScrollStart = () => {
        this._pendingScrollStart = true;
        this.el.scrollTo({ left: 0, behavior: "smooth" });
      };
      this.el.addEventListener("lg:scroll-start", this._onScrollStart);

      // Seed the marker-position cache so `updated()` only re-scrolls
      // when the today marker actually MOVES (not on every unrelated
      // server patch).
      const marker = this.el.querySelector(".lg-today");
      this._lastMarkerLeft = marker ? marker.style.left || "" : "";

      if (this.el.dataset.autoScrollToday === "true") {
        // Wait one frame so layout is settled before we measure.
        requestAnimationFrame(() => this._scrollToToday(false));
      }
    },
    destroyed() {
      this.el.removeEventListener("lg:scroll-today", this._onScrollToday);
      this.el.removeEventListener("lg:scroll-start", this._onScrollStart);
    },
    updated() {
      // A pending scroll-to-start wins this patch cycle: re-assert left:0 (the
      // refit re-rendered after the click-time scroll) and seed the marker
      // cache so the today-follow below doesn't immediately yank us off the
      // start on the next unrelated patch.
      if (this._pendingScrollStart) {
        this._pendingScrollStart = false;
        const marker = this.el.querySelector(".lg-today");
        this._lastMarkerLeft = marker ? marker.style.left || "" : "";
        requestAnimationFrame(() =>
          this.el.scrollTo({ left: 0, behavior: "auto" }),
        );
        return;
      }

      // Only re-scroll when the today marker actually moved (e.g.
      // date-range navigation shifts it). Without this check, every
      // unrelated LiveView patch (popover-open round-trips, expand /
      // collapse, etc.) would yank the user's scroll position back to
      // today and feel broken.
      if (this.el.dataset.autoScrollToday !== "true") return;

      const marker = this.el.querySelector(".lg-today");
      if (!marker) return;

      const left = marker.style.left || "";
      if (this._lastMarkerLeft === left) return;
      this._lastMarkerLeft = left;

      requestAnimationFrame(() => this._scrollToToday(false));
    },
    _scrollToToday(smooth) {
      const marker = this.el.querySelector(".lg-today");
      if (!marker) return;

      const markerRect = marker.getBoundingClientRect();
      const containerRect = this.el.getBoundingClientRect();

      // Marker's x relative to the scrollable content (includes current scroll).
      const markerOffset =
        markerRect.left - containerRect.left + this.el.scrollLeft;

      // Exclude the sticky-ish label column from the visible timeline width
      // so "center" means center of the bar area, not center of the whole
      // viewport (which would land today too far right).
      const labelHeader = this.el.querySelector(".lg-label-header");
      const labelWidth = labelHeader ? labelHeader.offsetWidth : 0;

      const visibleTimelineWidth = this.el.clientWidth - labelWidth;
      const targetScroll =
        markerOffset - labelWidth - visibleTimelineWidth / 2;

      this.el.scrollTo({
        left: Math.max(0, targetScroll),
        behavior: smooth ? "smooth" : "auto",
      });
    },
  };

  // ============================================================
  // LgBarPopover — click bar to open a popover anchored to
  // the bar with full title + custom action buttons. Click anywhere
  // outside the popover (or on a different bar) to close.
  // ============================================================
  //
  // Wired automatically via `phx-hook` on bar elements that have
  // `event.extra.actions` configured. Each hooked bar carries a
  // `data-popover-target="<id>"` pointing to its sibling popover div
  // (rendered next to the bar so the bar's overflow-hidden doesn't
  // clip the popover).
  //
  // Touch devices: a normal `click` event fires on tap, so this hook
  // works for both desktop click and mobile tap with no special-case
  // pointer handling.
  window.PhoenixLiveGanttHooks.LgBarPopover = {
    mounted() {
      this._onClick = (e) => {
        // Clicks inside the popover itself shouldn't toggle / close
        // (action button clicks bubble through and would otherwise
        // immediately close the popover they live in).
        const popover = this._popover();
        if (popover && popover.contains(e.target)) return;

        // Clicks on the sub-project expand/collapse chevron must
        // pass through to LiveView's phx-click. We do NOT toggle
        // the popover, so the chevron's `phx-click` fires normally.
        if (e.target.closest(".lg-subproject-chevron")) return;

        // Deliberately DON'T stopPropagation: LiveView binds phx-click on the
        // document (bubble phase), so stopping here would swallow the bar's own
        // `on_event_click`. Letting the click bubble means a hooked bar fires
        // `on_event_click` exactly like an un-hooked one. The just-opened popover
        // survives the bubble because the global outside-click handler guards on
        // `bar.contains` / `popover.contains` before closing anything.
        this._toggle();
      };

      this.el.addEventListener("click", this._onClick);

      // Keyboard parity: the bar/milestone carries tabindex=0 + role=button, so
      // Enter/Space must open the popover like a click. Only when the element
      // ITSELF is focused (not a child action button, whose own handler fires).
      this._onKeydown = (e) => {
        if (e.target !== this.el) return;
        if (e.key !== "Enter" && e.key !== " ") return;
        e.preventDefault();
        const wasOpen = this._isOpen();
        // Dispatch a REAL click so keyboard activation is identical to a mouse
        // click: it toggles the popover (via `_onClick`) AND fires any phx-click
        // (`on_event_click`). Calling `_toggle()` directly would open the popover
        // but silently skip the server event a mouse user gets. The synthetic
        // click bubbles to the global outside-click handler just like a mouse
        // click, which keeps the popover open via its containment guards.
        this.el.click();
        if (!wasOpen && this._isOpen()) {
          // Moved focus INTO the popover so Tab cycles its actions and Escape
          // closes in context. Made programmatically focusable (tabindex -1).
          const p = this._popover();
          if (p) {
            p.setAttribute("tabindex", "-1");
            p.focus({ preventScroll: true });
          }
        } else {
          this.el.focus({ preventScroll: true });
        }
      };
      this.el.addEventListener("keydown", this._onKeydown);

      // Track this bar in the document-level registry so the global
      // outside-click handler can find every open popover and close
      // them in one pass.
      window.PhoenixLiveGanttHooks.LgBarPopover._installGlobal();
      window.PhoenixLiveGanttHooks.LgBarPopover._bars.add(this.el);

      // If this bar was the active popover BEFORE a LiveView diff
      // re-rendered it (same DOM id, new mount), restore the open
      // state from the chart-keyed module-level registry. Without
      // this, opening a popover then triggering any server-side
      // patch would silently drop the open state.
      this._restoreIfActive();
    },

    destroyed() {
      this.el.removeEventListener("click", this._onClick);
      this.el.removeEventListener("keydown", this._onKeydown);
      window.PhoenixLiveGanttHooks.LgBarPopover._bars.delete(this.el);
      // Do NOT clear `_activeBarByChart` here — the bar might be
      // re-mounting after a diff and we want `mounted()` to restore.
    },

    updated() {
      // LiveView diffs wipe JS-applied classes (`lg-faded`, `lg-pinned`)
      // and reset element attributes. If this chart currently has an
      // open popover, replay the fade + pin pass so the visual state
      // survives the diff.
      this._restoreIfActive();
    },

    _restoreIfActive() {
      const chartEl = this.el.closest(".lg-wrap");
      if (!chartEl) return;
      const active =
        window.PhoenixLiveGanttHooks.LgBarPopover._activeBarByChart.get(chartEl);
      if (!active) return;

      // Find the element that owned the open popover (could be a bar
      // OR a label — both carry the LgBarPopover hook). Use the
      // popover-target id, not the event id, because bar and label
      // share the event id but have distinct popover targets.
      const activeEl = chartEl.querySelector(
        `[data-popover-target="${CSS.escape(active.popoverId)}"]`,
      );
      if (!activeEl) {
        // The active element vanished (e.g. server removed the event).
        // Clear the stale registration so future restores no-op.
        window.PhoenixLiveGanttHooks.LgBarPopover._activeBarByChart.delete(chartEl);
        return;
      }

      const popover = document.getElementById(active.popoverId);
      if (popover) popover.classList.remove("hidden");
      activeEl.dataset.popoverOpen = "true";

      window.PhoenixLiveGanttHooks.LgBarPopover._applyTreeFade(activeEl, active.eventId);
      if (popover) {
        requestAnimationFrame(() => {
          window.PhoenixLiveGanttHooks.LgBarPopover._pushBottomBadges(
            activeEl,
            popover,
          );
        });
      }
    },

    _popover() {
      const id = this.el.dataset.popoverTarget;
      return id ? document.getElementById(id) : null;
    },

    _isOpen() {
      const p = this._popover();
      return p && !p.classList.contains("hidden");
    },

    _open() {
      const p = this._popover();
      if (!p) return;
      window.PhoenixLiveGanttHooks.LgBarPopover._closeAll();

      // Re-anchor the popover to the bar's CURRENT geometry before showing it.
      // The popover is `phx-update="ignore"` so LiveView never updates its
      // server-rendered left/width — those go stale when the chart re-renders
      // with new geometry (zoom switch, data change) while the bar id stays
      // put. The bar element itself is always up to date, so copy from it. Only
      // for bar popovers (label popovers are anchored vertically, not by left).
      //
      // The anchor is the BAR, even when the trigger is the too-small-to-see
      // triangle marker: a tiny bar is opened via `.lg-tiny-marker`, whose own
      // `style.left` is `0` (it lives inside the bar-width tiny-container), so we
      // resolve the bar via the popover's `data-popover-for` and copy ITS
      // left/width. Without this the marker-opened popover kept the stale frozen
      // position and landed in the wrong place after a zoom change.
      let anchor = null;

      if (
        this.el.classList.contains("lg-bar") ||
        this.el.classList.contains("lg-milestone")
      ) {
        anchor = this.el;
      } else if (this.el.classList.contains("lg-tiny-marker")) {
        anchor = document.getElementById(p.dataset.popoverFor || "");
      }

      if (anchor) {
        if (anchor.style.left) p.style.left = anchor.style.left;
        if (anchor.style.width) p.style.minWidth = anchor.style.width;
      }

      p.classList.remove("hidden");
      this.el.dataset.popoverOpen = "true";

      // Highlight the active task's dependency tree; fade everything else.
      // Walks the connector graph BACKWARD from the active task (its transitive
      // ancestors — what it depends on), plus the parent_id chain. Descendants
      // are intentionally not included (see `_collectTree`).
      const eventId = this.el.dataset.eventId;
      const popoverId = this.el.dataset.popoverTarget;
      if (eventId && popoverId) {
        // Register as the chart's active popover so `updated()` can
        // restore the open state after LiveView diffs. Track the
        // popover-target id (not just the event id) because bar and
        // label rows share an event id but expose distinct popovers.
        const chartEl = this.el.closest(".lg-wrap");
        if (chartEl) {
          window.PhoenixLiveGanttHooks.LgBarPopover._activeBarByChart.set(chartEl, {
            popoverId,
            eventId,
          });
        }

        window.PhoenixLiveGanttHooks.LgBarPopover._applyTreeFade(this.el, eventId);
      }

      // Push bottom-corner badges of the active task down so the
      // expanded popover doesn't sit on top of them. Measured AFTER
      // the popover becomes visible (`requestAnimationFrame` ensures
      // layout is settled), so we get the popover's actual height.
      requestAnimationFrame(() => {
        window.PhoenixLiveGanttHooks.LgBarPopover._pushBottomBadges(this.el, p);
      });
    },

    _close() {
      const p = this._popover();
      if (!p) return;
      // If focus is inside the popover (keyboard user closing via Escape),
      // return it to the trigger so it isn't lost to <body>.
      if (p.contains(document.activeElement)) {
        this.el.focus({ preventScroll: true });
      }
      p.classList.add("hidden");
      delete this.el.dataset.popoverOpen;

      // Clear the chart's active bar registration.
      const chartEl = this.el.closest(".lg-wrap");
      if (chartEl) {
        window.PhoenixLiveGanttHooks.LgBarPopover._activeBarByChart.delete(chartEl);
      }

      // Restore everything else.
      window.PhoenixLiveGanttHooks.LgBarPopover._clearTreeFade(this.el);
      window.PhoenixLiveGanttHooks.LgBarPopover._restoreBottomBadges(this.el);
    },

    _toggle() {
      this._isOpen() ? this._close() : this._open();
    },
  };

  // Document-wide outside-click + Escape handlers, installed once.
  // Tracks every mounted bar so a single listener handles all of
  // them (avoids registering N document listeners).
  window.PhoenixLiveGanttHooks.LgBarPopover._bars = new Set();
  window.PhoenixLiveGanttHooks.LgBarPopover._globalInstalled = false;

  // Chart wrap element → active (open) event id. Survives LiveView
  // diffs so a re-mounted bar hook can restore its popover/fade state.
  // Cleaned up by `_close` / `_closeAll` and on outside-click close.
  window.PhoenixLiveGanttHooks.LgBarPopover._activeBarByChart = new WeakMap();

  window.PhoenixLiveGanttHooks.LgBarPopover._installGlobal = function () {
    if (this._globalInstalled) return;
    this._globalInstalled = true;

    document.addEventListener("click", (e) => {
      // Clicks inside another chart shouldn't close popovers in THIS
      // chart — each gantt instance manages its own popover state.
      // Clicks entirely outside any chart close everything.
      const clickWrap = e.target.closest(".lg-wrap");

      this._bars.forEach((bar) => {
        if (bar.dataset.popoverOpen !== "true") return;

        const popoverId = bar.dataset.popoverTarget;
        const popover = popoverId ? document.getElementById(popoverId) : null;

        // Click inside this bar OR its popover is fine — keep open.
        if (bar.contains(e.target)) return;
        if (popover && popover.contains(e.target)) return;

        // Click landed inside a DIFFERENT chart — leave this chart's
        // popover alone so the two instances don't trample each other.
        const barWrap = bar.closest(".lg-wrap");
        if (clickWrap && barWrap && clickWrap !== barWrap) return;

        // Click landed elsewhere — close + restore the faded tree
        // and any shifted bottom badges.
        if (popover) popover.classList.add("hidden");
        delete bar.dataset.popoverOpen;
        if (barWrap) this._activeBarByChart.delete(barWrap);
        this._clearTreeFade(bar);
        this._restoreBottomBadges(bar);
      });
    });

    document.addEventListener("keydown", (e) => {
      if (e.key !== "Escape") return;
      this._closeAll();
    });
  };

  window.PhoenixLiveGanttHooks.LgBarPopover._closeAll = function () {
    this._bars.forEach((bar) => {
      if (bar.dataset.popoverOpen !== "true") return;
      const popoverId = bar.dataset.popoverTarget;
      const popover = popoverId ? document.getElementById(popoverId) : null;
      // Keyboard close (Escape routes here): if focus is inside this popover,
      // return it to the trigger so it isn't lost to <body>. The instance
      // `_close` does this too, but Escape goes through the global handler.
      if (popover && popover.contains(document.activeElement)) {
        bar.focus({ preventScroll: true });
      }
      if (popover) popover.classList.add("hidden");
      delete bar.dataset.popoverOpen;
      const wrap = bar.closest(".lg-wrap");
      if (wrap) this._activeBarByChart.delete(wrap);
      this._clearTreeFade(bar);
      this._restoreBottomBadges(bar);
    });
  };

  // Walk the connector graph BACKWARD from `activeId` to collect every
  // task that's required to reach it — i.e., its transitive ancestors.
  // Descendants (things that depend on this task) are NOT included;
  // they aren't required for THIS task's completion.
  //
  // For tasks inside a sub-project we also walk the parent_id chain
  // and the parent sub-project's own incoming connectors. A nested
  // task implicitly inherits everything its container sub-project
  // depends on, so those should stay full color too.
  window.PhoenixLiveGanttHooks.LgBarPopover._collectTree = function (chartEl, activeId) {
    // Reverse adjacency: for each task, who points INTO it.
    const reverse = new Map();
    chartEl.querySelectorAll("[data-from-id][data-to-id]").forEach((c) => {
      const f = c.dataset.fromId;
      const t = c.dataset.toId;
      if (!reverse.has(t)) reverse.set(t, new Set());
      reverse.get(t).add(f);
    });

    // Parent chain: each task → its parent_id (if any). Multiple DOM
    // nodes (bar + label + milestone) carry the same data-parent-id;
    // the Map dedupes them by event id.
    const parentOf = new Map();
    chartEl.querySelectorAll("[data-event-id][data-parent-id]").forEach((el) => {
      const id = el.dataset.eventId;
      const pid = el.dataset.parentId;
      if (id && pid) parentOf.set(id, pid);
    });

    const tree = new Set([activeId]);
    const queue = [activeId];
    while (queue.length) {
      const id = queue.shift();

      // Incoming connector edges (predecessors).
      const incoming = reverse.get(id);
      if (incoming) {
        incoming.forEach((from) => {
          if (!tree.has(from)) {
            tree.add(from);
            queue.push(from);
          }
        });
      }

      // Walk up parent_id — the sub-project containing this task
      // contributes its OWN required chain too.
      const parent = parentOf.get(id);
      if (parent && !tree.has(parent)) {
        tree.add(parent);
        queue.push(parent);
      }
    }
    return tree;
  };

  // Add `lg-faded` to every bar/label/connector NOT in the active
  // task's dependency tree. Scoped to the chart that contains the
  // active bar so multiple charts on one page don't interfere.
  window.PhoenixLiveGanttHooks.LgBarPopover._applyTreeFade = function (activeEl, activeId) {
    const chartEl = activeEl.closest(".lg-wrap");
    if (!chartEl) return;

    const tree = this._collectTree(chartEl, activeId);

    // Pass 1: bars + labels + milestones + bar-badges (anything carrying
    // data-event-id). Build up the set of groups that have at least one
    // task in the tree so we know which group headers stay full color.
    const groupsInTree = new Set();
    chartEl.querySelectorAll("[data-event-id]").forEach((el) => {
      if (tree.has(el.dataset.eventId)) {
        if (el.dataset.group) groupsInTree.add(el.dataset.group);
      } else {
        el.classList.add("lg-faded");
      }
    });

    // Pass 2: group headers (label-side) + group spacers (timeline-side).
    // Fade if NO event in their group is in the tree.
    chartEl
      .querySelectorAll(".lg-group[data-group], .lg-group-spacer[data-group]")
      .forEach((el) => {
        if (!groupsInTree.has(el.dataset.group)) {
          el.classList.add("lg-faded");
        }
      });

    // Pass 3: connectors — keep only edges where BOTH endpoints are in
    // the tree.
    chartEl.querySelectorAll("[data-from-id][data-to-id]").forEach((c) => {
      const inTree = tree.has(c.dataset.fromId) && tree.has(c.dataset.toId);
      if (!inTree) {
        c.classList.add("lg-faded");
      }
    });

    // Pass 4: PIN the active task's elements (bar, label, badges) with
    // `lg-pinned` so they're guaranteed full color even if some
    // other rule later tries to dim them. The active task isn't faded
    // by pass 1 anyway, but pinning gives a hard guarantee.
    chartEl
      .querySelectorAll(`[data-event-id="${CSS.escape(activeId)}"]`)
      .forEach((el) => el.classList.add("lg-pinned"));
  };

  // Strip every `lg-faded` mark inside the chart that owns
  // `activeEl`. Called on popover close + before opening a different
  // popover (so transitions are clean).
  window.PhoenixLiveGanttHooks.LgBarPopover._clearTreeFade = function (activeEl) {
    const chartEl = activeEl.closest(".lg-wrap");
    if (!chartEl) return;
    chartEl
      .querySelectorAll(".lg-faded")
      .forEach((el) => el.classList.remove("lg-faded"));
    chartEl
      .querySelectorAll(".lg-pinned")
      .forEach((el) => el.classList.remove("lg-pinned"));
  };

  // When the popover opens it can extend far below the bar
  // (title + subtitle + actions row). Any bottom-corner badge of
  // the active task would then sit inside the popover's visual
  // footprint and feel like it belongs to the popup, not the row
  // below it. Slide every bottom-corner badge down by exactly the
  // overflow amount so it lands clear of the open popover.
  window.PhoenixLiveGanttHooks.LgBarPopover._pushBottomBadges = function (activeEl, popover) {
    const chartEl = activeEl.closest(".lg-wrap");
    if (!chartEl) return;

    const eventId = activeEl.dataset.eventId;
    if (!eventId) return;

    // How much the popover extends below the bar's row. Popover sits
    // at top: 4px in the row container; row height comes from the
    // badge's data-row-px (set at render time). Negative or zero
    // means the popover fits inside the row → no push needed.
    const popoverHeight = popover.offsetHeight;
    const popoverTop = 4; // matches `popover_top_inset` on the server

    chartEl
      .querySelectorAll(
        `[data-event-id="${CSS.escape(eventId)}"][data-badge-corner^="bottom_"]`,
      )
      .forEach((badge) => {
        const rowPx = parseInt(badge.dataset.rowPx || "40", 10);
        // The badge's natural bottom edge sits at `rowPx` (top: rowPx-16,
        // height: 16). Shift it so it lands just below the popover
        // bottom — `popoverTop + popoverHeight + small gap`.
        const targetTop = popoverTop + popoverHeight + 4;
        const naturalBottom = rowPx;
        const shift = Math.max(0, targetTop - naturalBottom);
        badge.style.transform = `translateY(${shift}px)`;
      });
  };

  // Reset transforms on bottom-corner badges so they slide back to
  // their natural position when the popover closes.
  window.PhoenixLiveGanttHooks.LgBarPopover._restoreBottomBadges = function (activeEl) {
    const chartEl = activeEl.closest(".lg-wrap");
    if (!chartEl) return;

    chartEl
      .querySelectorAll('.lg-bar-badge[data-badge-corner^="bottom_"]')
      .forEach((badge) => {
        badge.style.transform = "";
      });
  };

  // Log initialization
  var hookCount = Object.keys(window.PhoenixLiveGanttHooks).length;
  if (typeof console !== "undefined" && console.debug) {
    console.debug(
      "[PhoenixLiveGantt] Initialized with " + hookCount + " hook(s):",
      Object.keys(window.PhoenixLiveGanttHooks)
    );
  }
})();