Skip to main content

priv/static/assets/phoenix_live_calendar.js

/**
 * PhoenixLiveCalendar JS Hooks
 *
 * Optional JavaScript hooks for enhanced interactions.
 * The calendar works without these — they add drag-to-select,
 * drag-to-move, drag-to-resize, and responsive container detection.
 *
 * Usage in your app.js:
 *
 *   import "../../deps/phoenix_live_calendar/priv/static/assets/phoenix_live_calendar.js"
 *
 *   let liveSocket = new LiveSocket("/live", Socket, {
 *     hooks: { ...window.PhoenixLiveCalendarHooks, ...myHooks }
 *   })
 */

(function () {
  "use strict";

  window.PhoenixLiveCalendarHooks = window.PhoenixLiveCalendarHooks || {};

  // ============================================================
  // TimeRangeSelect — drag to select a time range on a time grid
  // ============================================================
  window.PhoenixLiveCalendarHooks.TimeRangeSelect = {
    mounted() {
      this.selecting = false;
      this.startSlot = null;
      this.endSlot = null;

      this._onPointerDown = (e) => {
        const slot = e.target.closest("[data-slot]");
        if (!slot) return;

        e.preventDefault();
        this.selecting = true;
        this.startSlot = slot.dataset.slot;
        this.endSlot = slot.dataset.slot;
        this.startDate = slot.closest("[data-date]")?.dataset.date;

        // Capture pointer for tracking outside element
        this.el.setPointerCapture(e.pointerId);
        this._highlightRange();
      };

      this._onPointerMove = (e) => {
        if (!this.selecting) return;

        const el = document.elementFromPoint(e.clientX, e.clientY);
        const slot = el?.closest("[data-slot]");
        if (slot && slot.dataset.slot !== this.endSlot) {
          this.endSlot = slot.dataset.slot;
          this._highlightRange();
        }
      };

      this._onPointerUp = (e) => {
        if (!this.selecting) return;
        this.selecting = false;

        // Clear visual highlight
        this._clearHighlight();

        // Determine the ordered range
        const start = this.startSlot < this.endSlot ? this.startSlot : this.endSlot;
        const end = this.startSlot < this.endSlot ? this.endSlot : this.startSlot;

        // Push final selection to server
        this.pushEventTo(this.el, "lc_range_select", {
          date: this.startDate,
          start_time: start,
          end_time: end,
        });
      };

      this._onKeyDown = (e) => {
        if (e.key === "Escape" && this.selecting) {
          this.selecting = false;
          this._clearHighlight();
        }
      };

      this.el.addEventListener("pointerdown", this._onPointerDown);
      this.el.addEventListener("pointermove", this._onPointerMove);
      this.el.addEventListener("pointerup", this._onPointerUp);
      document.addEventListener("keydown", this._onKeyDown);

      // Prevent scroll on touch during selection
      this.el.style.touchAction = "none";
    },

    _highlightRange() {
      const slots = this.el.querySelectorAll("[data-slot]");
      const start = this.startSlot < this.endSlot ? this.startSlot : this.endSlot;
      const end = this.startSlot < this.endSlot ? this.endSlot : this.startSlot;

      slots.forEach((slot) => {
        const t = slot.dataset.slot;
        if (t >= start && t <= end) {
          slot.classList.add("cal-selecting");
        } else {
          slot.classList.remove("cal-selecting");
        }
      });
    },

    _clearHighlight() {
      this.el.querySelectorAll(".cal-selecting").forEach((el) => {
        el.classList.remove("cal-selecting");
      });
    },

    destroyed() {
      this.el.removeEventListener("pointerdown", this._onPointerDown);
      this.el.removeEventListener("pointermove", this._onPointerMove);
      this.el.removeEventListener("pointerup", this._onPointerUp);
      document.removeEventListener("keydown", this._onKeyDown);
    },
  };

  // ============================================================
  // EventDrag — drag to move an event to a new time/date
  // ============================================================
  window.PhoenixLiveCalendarHooks.EventDrag = {
    mounted() {
      this._dragging = null;
      this._ghost = null;
      this._startX = 0;
      this._startY = 0;

      this._onPointerDown = (e) => {
        const eventEl = e.target.closest("[data-event-id]");
        if (!eventEl || eventEl.dataset.editable === "false") return;

        // Require minimum movement before starting drag
        this._startX = e.clientX;
        this._startY = e.clientY;
        this._pendingDrag = eventEl;
        this._pointerId = e.pointerId;
      };

      this._onPointerMove = (e) => {
        if (this._pendingDrag && !this._dragging) {
          const dx = Math.abs(e.clientX - this._startX);
          const dy = Math.abs(e.clientY - this._startY);

          // Min 5px movement to start drag
          if (dx + dy > 5) {
            this._startDrag(this._pendingDrag, e);
            this._pendingDrag = null;
          }
          return;
        }

        if (!this._dragging) return;

        // Move ghost element
        if (this._ghost) {
          this._ghost.style.left = e.clientX - this._offsetX + "px";
          this._ghost.style.top = e.clientY - this._offsetY + "px";
        }

        // Highlight drop target
        const target = document.elementFromPoint(e.clientX, e.clientY);
        const slot = target?.closest("[data-slot]");
        const dateCol = target?.closest("[data-date]");

        this.el.querySelectorAll(".cal-drop-target").forEach((el) =>
          el.classList.remove("cal-drop-target")
        );

        if (slot) slot.classList.add("cal-drop-target");
        else if (dateCol) dateCol.classList.add("cal-drop-target");
      };

      this._onPointerUp = (e) => {
        this._pendingDrag = null;

        if (!this._dragging) return;

        // Find drop target
        if (this._ghost) {
          this._ghost.style.display = "none";
        }
        const target = document.elementFromPoint(e.clientX, e.clientY);
        if (this._ghost) {
          this._ghost.style.display = "";
        }

        const slot = target?.closest("[data-slot]");
        const dateCol = target?.closest("[data-date]");

        // Clean up
        this._cleanupDrag();

        // Push event to server
        if (slot || dateCol) {
          this.pushEventTo(this.el, "lc_event_drop", {
            event_id: this._dragging,
            new_date: dateCol?.dataset.date,
            new_time: slot?.dataset.slot,
            resource_id:
              dateCol?.dataset.resourceId || slot?.closest("[data-resource-id]")?.dataset.resourceId,
          });
        }

        this._dragging = null;
      };

      this.el.addEventListener("pointerdown", this._onPointerDown);
      document.addEventListener("pointermove", this._onPointerMove);
      document.addEventListener("pointerup", this._onPointerUp);
    },

    _startDrag(eventEl, e) {
      this._dragging = eventEl.dataset.eventId;

      // Create ghost
      this._ghost = eventEl.cloneNode(true);
      this._ghost.classList.add("cal-ghost");
      this._ghost.style.position = "fixed";
      this._ghost.style.zIndex = "9999";
      this._ghost.style.opacity = "0.7";
      this._ghost.style.pointerEvents = "none";
      this._ghost.style.width = eventEl.offsetWidth + "px";

      const rect = eventEl.getBoundingClientRect();
      this._offsetX = e.clientX - rect.left;
      this._offsetY = e.clientY - rect.top;
      this._ghost.style.left = rect.left + "px";
      this._ghost.style.top = rect.top + "px";

      document.body.appendChild(this._ghost);

      // Dim original
      eventEl.classList.add("cal-dragging");

      // Capture pointer
      this.el.setPointerCapture(this._pointerId);
    },

    _cleanupDrag() {
      if (this._ghost) {
        this._ghost.remove();
        this._ghost = null;
      }

      this.el.querySelectorAll(".cal-dragging").forEach((el) =>
        el.classList.remove("cal-dragging")
      );
      this.el.querySelectorAll(".cal-drop-target").forEach((el) =>
        el.classList.remove("cal-drop-target")
      );
    },

    destroyed() {
      this._cleanupDrag();
      this.el.removeEventListener("pointerdown", this._onPointerDown);
      document.removeEventListener("pointermove", this._onPointerMove);
      document.removeEventListener("pointerup", this._onPointerUp);
    },
  };

  // ============================================================
  // EventResize — drag event edge to resize duration
  // ============================================================
  window.PhoenixLiveCalendarHooks.EventResize = {
    mounted() {
      this._resizing = null;

      this._onPointerDown = (e) => {
        const handle = e.target.closest("[data-resize-handle]");
        if (!handle) return;

        e.preventDefault();
        e.stopPropagation();

        const eventEl = handle.closest("[data-event-id]");
        if (!eventEl || eventEl.dataset.editable === "false") return;

        this._resizing = {
          eventId: eventEl.dataset.eventId,
          edge: handle.dataset.resizeHandle, // "top" or "bottom"
          startY: e.clientY,
          originalHeight: eventEl.offsetHeight,
          originalTop: eventEl.offsetTop,
          element: eventEl,
        };

        this.el.setPointerCapture(e.pointerId);
        eventEl.classList.add("cal-resizing");
      };

      this._onPointerMove = (e) => {
        if (!this._resizing) return;

        const dy = e.clientY - this._resizing.startY;

        if (this._resizing.edge === "bottom") {
          const newHeight = Math.max(20, this._resizing.originalHeight + dy);
          this._resizing.element.style.height = newHeight + "px";
        } else if (this._resizing.edge === "top") {
          const newHeight = Math.max(20, this._resizing.originalHeight - dy);
          const newTop = this._resizing.originalTop + dy;
          this._resizing.element.style.height = newHeight + "px";
          this._resizing.element.style.top = newTop + "px";
        }
      };

      this._onPointerUp = (e) => {
        if (!this._resizing) return;

        // Find the nearest slot to the new edge position
        const rect = this._resizing.element.getBoundingClientRect();
        const targetY =
          this._resizing.edge === "bottom" ? rect.bottom : rect.top;

        const allSlots = this.el.querySelectorAll("[data-slot]");
        let nearestSlot = null;
        let nearestDist = Infinity;

        allSlots.forEach((slot) => {
          const slotRect = slot.getBoundingClientRect();
          const dist = Math.abs(slotRect.top - targetY);
          if (dist < nearestDist) {
            nearestDist = dist;
            nearestSlot = slot;
          }
        });

        this._resizing.element.classList.remove("cal-resizing");
        // Reset inline styles — server will re-render
        this._resizing.element.style.height = "";
        this._resizing.element.style.top = "";

        if (nearestSlot) {
          this.pushEventTo(this.el, "lc_event_resize", {
            event_id: this._resizing.eventId,
            edge: this._resizing.edge,
            new_time: nearestSlot.dataset.slot,
          });
        }

        this._resizing = null;
      };

      this.el.addEventListener("pointerdown", this._onPointerDown);
      this.el.addEventListener("pointermove", this._onPointerMove);
      this.el.addEventListener("pointerup", this._onPointerUp);
    },

    destroyed() {
      this.el.removeEventListener("pointerdown", this._onPointerDown);
      this.el.removeEventListener("pointermove", this._onPointerMove);
      this.el.removeEventListener("pointerup", this._onPointerUp);
    },
  };

  // ============================================================
  // ResponsiveContainer — reports container width for adaptive views
  // ============================================================
  window.PhoenixLiveCalendarHooks.ResponsiveContainer = {
    mounted() {
      this._lastWidth = null;

      this._observer = new ResizeObserver((entries) => {
        for (const entry of entries) {
          const width = Math.round(entry.contentRect.width);

          // Only push if width changed meaningfully (>10px)
          if (
            this._lastWidth === null ||
            Math.abs(width - this._lastWidth) > 10
          ) {
            this._lastWidth = width;
            this._debouncedPush(width);
          }
        }
      });

      this._observer.observe(this.el);

      // Debounce helper
      this._timer = null;
      this._debouncedPush = (width) => {
        clearTimeout(this._timer);
        this._timer = setTimeout(() => {
          this.pushEventTo(this.el, "lc_container_resized", { width: width });
        }, 150);
      };
    },

    destroyed() {
      if (this._observer) {
        this._observer.disconnect();
      }
      clearTimeout(this._timer);
    },
  };

  // ============================================================
  // TouchHandler — long-press detection for mobile drag
  // ============================================================
  window.PhoenixLiveCalendarHooks.TouchHandler = {
    mounted() {
      this._longPressDelay = parseInt(this.el.dataset.longPressDelay || "500");
      this._timer = null;

      this._onTouchStart = (e) => {
        const target = e.target.closest("[data-event-id]");
        if (!target) return;

        this._timer = setTimeout(() => {
          target.classList.add("cal-long-press");
          // Trigger a custom event that EventDrag can pick up
          target.dispatchEvent(
            new PointerEvent("pointerdown", {
              clientX: e.touches[0].clientX,
              clientY: e.touches[0].clientY,
              pointerId: 0,
              bubbles: true,
            })
          );
        }, this._longPressDelay);
      };

      this._onTouchMove = () => {
        clearTimeout(this._timer);
      };

      this._onTouchEnd = () => {
        clearTimeout(this._timer);
        this.el.querySelectorAll(".cal-long-press").forEach((el) =>
          el.classList.remove("cal-long-press")
        );
      };

      this.el.addEventListener("touchstart", this._onTouchStart, {
        passive: true,
      });
      this.el.addEventListener("touchmove", this._onTouchMove, {
        passive: true,
      });
      this.el.addEventListener("touchend", this._onTouchEnd);
    },

    destroyed() {
      clearTimeout(this._timer);
      this.el.removeEventListener("touchstart", this._onTouchStart);
      this.el.removeEventListener("touchmove", this._onTouchMove);
      this.el.removeEventListener("touchend", this._onTouchEnd);
    },
  };

  // ============================================================
  // PopoverPause — pauses tickers while a popover/modal is open
  // ============================================================
  window.PhoenixLiveCalendarHooks.PopoverPause = {
    mounted() {
      window.dispatchEvent(
        new CustomEvent("lc:ticker-pause", { detail: { paused: true } })
      );
    },
    destroyed() {
      window.dispatchEvent(
        new CustomEvent("lc:ticker-pause", { detail: { paused: false } })
      );
    },
  };

  // ============================================================
  // MarkerTicker — cycles through day marker labels one at a time
  // ============================================================
  window.PhoenixLiveCalendarHooks.MarkerTicker = {
    mounted() {
      this._items = this.el.querySelectorAll("[data-ticker-index]");
      this._count = this._items.length;
      this._current = 0;
      this._paused = false;
      this._interval = parseInt(this.el.dataset.interval || "3000");

      if (this._count <= 1) return;

      this._timer = setInterval(() => {
        if (this._paused) return;
        this._advance();
      }, this._interval);

      // Pause on hover so user can read
      this.el.addEventListener("mouseenter", () => {
        this._paused = true;
      });
      this.el.addEventListener("mouseleave", () => {
        this._paused = true;
        // Resume after a short delay to avoid jarring immediate switch
        setTimeout(() => { this._paused = false; }, 500);
      });

      // Listen for external pause (e.g., popover open)
      this._onPause = (e) => {
        if (e.detail && e.detail.paused !== undefined) {
          this._paused = e.detail.paused;
        }
      };
      window.addEventListener("lc:ticker-pause", this._onPause);
    },

    _advance() {
      this._items[this._current].classList.remove("opacity-100");
      this._items[this._current].classList.add("opacity-0", "pointer-events-none");
      this._current = (this._current + 1) % this._count;
      this._items[this._current].classList.remove("opacity-0", "pointer-events-none");
      this._items[this._current].classList.add("opacity-100");
    },

    destroyed() {
      clearInterval(this._timer);
      window.removeEventListener("lc:ticker-pause", this._onPause);
    },
  };

  // ============================================================
  // SyncAnimations — keep the overdue overlay consistent across cells
  // ============================================================
  // Two jobs, both so a striped/animated overlay reads as one continuous thing
  // across separately-rendered day-cells:
  //
  //  1. Phase: CSS animations begin when their element is first rendered, so
  //     cells patched in at different times (e.g. paging a calendar) drift out
  //     of phase. We re-anchor every animation in the subtree to the same start
  //     time (0 = the document timeline origin); per-element `animation-delay` is
  //     preserved, so a staggered wave stays staggered AND synced.
  //
  //  2. Alignment: a per-cell background gradient normally restarts at each
  //     cell's own box, so diagonal stripes don't line up cell-to-cell. We set
  //     `--pk-bg-x/y` on each `.pk-overdue` to its offset from this container's
  //     origin, so every cell shows its slice of ONE shared pattern. The offset
  //     is relative (not viewport-fixed), so it stays correct on scroll.
  //
  // Recomputed on mount, on subtree changes (MutationObserver — paging) and on
  // size changes (ResizeObserver — window resize + becoming visible after a tab
  // switch). Progressive enhancement: without it the CSS still renders, the
  // stripes just don't line up / animations can drift after a re-render.
  window.PhoenixLiveCalendarHooks.SyncAnimations = {
    mounted() {
      this._apply();

      if (typeof MutationObserver !== "undefined") {
        this._observer = new MutationObserver(() => this._apply());
        this._observer.observe(this.el, { childList: true, subtree: true });
      }

      if (typeof ResizeObserver !== "undefined") {
        this._resize = new ResizeObserver(() => this._apply());
        this._resize.observe(this.el);
      }
    },

    _apply() {
      if (this._scheduled) return;
      this._scheduled = true;
      requestAnimationFrame(() => {
        this._scheduled = false;
        this._alignStripes();
        this._syncAnimations();
      });
    },

    // Anchor each overdue cell's gradient to this container's origin so the
    // diagonals line up across cells/rows.
    _alignStripes() {
      const root = this.el.getBoundingClientRect();
      this.el.querySelectorAll(".pk-overdue").forEach((el) => {
        const r = el.getBoundingClientRect();
        el.style.setProperty("--pk-bg-x", Math.round(root.left - r.left) + "px");
        el.style.setProperty("--pk-bg-y", Math.round(root.top - r.top) + "px");
      });
    },

    _syncAnimations() {
      if (!this.el.getAnimations) return;
      this.el.getAnimations({ subtree: true }).forEach((a) => {
        try {
          a.startTime = 0;
        } catch (e) {
          /* animation not yet ready / no settable startTime — ignore */
        }
      });
    },

    destroyed() {
      if (this._observer) this._observer.disconnect();
      if (this._resize) this._resize.disconnect();
    },
  };

  // ============================================================
  // PhoenixLiveCalendarContainer — composite hook for the main container
  // ============================================================
  window.PhoenixLiveCalendarHooks.PhoenixLiveCalendarContainer = {
    mounted() {
      // Initialize sub-hooks on the same element
      var hooks = [
        "TimeRangeSelect",
        "EventDrag",
        "EventResize",
        "ResponsiveContainer",
        "TouchHandler",
      ];
      this._subHooks = [];

      hooks.forEach((name) => {
        var hook = Object.create(window.PhoenixLiveCalendarHooks[name]);
        hook.el = this.el;
        hook.pushEvent = this.pushEvent.bind(this);
        hook.pushEventTo = this.pushEventTo.bind(this);
        hook.handleEvent = this.handleEvent.bind(this);
        hook.liveSocket = this.liveSocket;

        if (hook.mounted) hook.mounted();
        this._subHooks.push(hook);
      });
    },

    updated() {
      this._subHooks.forEach((hook) => {
        if (hook.updated) hook.updated();
      });
    },

    destroyed() {
      this._subHooks.forEach((hook) => {
        if (hook.destroyed) hook.destroyed();
      });
    },
  };
})();