PageSourceSearch

https://railsblocks.com/assets/controllers/dock_controller-ec5240a…1fd9cb94c79709b468e79b944c59f475f963055b.js

js railsblocks.com collected 2026-09-25 20:23:11 UTC 12,286 bytes, 334 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2import { animate, stagger, spring, transform } from "motion";
3import { computePosition, offset, flip, shift } from "@floating-ui/dom";
4
5export default class extends Controller {
6  static targets = ["icon", "mobileMenu", "mobileButton", "tooltip", "tooltipContent"];
7
8  connect() {
9    this.isToggling = false;
10    this.initDesktopHover();
11    this.initTooltips();
12
13    // Update active menu styling on Turbo page loads.
14    this.updateActiveMenu();
15    this.updateActiveMenuBound = this.updateActiveMenu.bind(this);
16    document.addEventListener("turbo:load", this.updateActiveMenuBound);
17
18    // Restore any tooltip if still hovering during a Turbo transition.
19    this.restoreTooltipBound = this.restoreTooltip.bind(this);
20    document.addEventListener("turbo:load", this.restoreTooltipBound);
21  }
22
23  // Desktop hover animations
24  initDesktopHover() {
25    this.mouseX = Infinity;
26    this.animations = new Map();
27
28    // Debounce the animation frame request
29    let frameRequest;
30    const updateAnimations = () => {
31      frameRequest = requestAnimationFrame(() => this.updateIconAnimations());
32    };
33
34    this.element.addEventListener("mousemove", (e) => {
35      this.mouseX = e.pageX;
36      if (frameRequest) {
37        cancelAnimationFrame(frameRequest);
38      }
39      updateAnimations();
40    });
41
42    this.element.addEventListener("mouseleave", () => {
43      this.mouseX = Infinity;
44      if (frameRequest) {
45        cancelAnimationFrame(frameRequest);
46      }
47      updateAnimations();
48    });
49  }
50
51  updateIconAnimations() {
52    // Add transform utility for smooth size mapping
53    const sizeTransformer = transform(
54      [-150, 0, 150], // Input range (distance from center)
55      [40, 80, 40], // Output range (icon size)
56    );
57
58    this.iconTargets.forEach((icon) => {
59      const rect = icon.getBoundingClientRect();
60      const distance = this.mouseX - (rect.x + rect.width / 2);
61      const targetSize = sizeTransformer(distance);
62      const iconElement = icon.querySelector("div:first-child");
63      if (!iconElement) return;
64
65      // Use spring animation with proper physics parameters
66      animate(
67        iconElement,
68        { width: `${targetSize}px`, height: `${targetSize}px` },
69        {
70          duration: 2,
71          easing: "easeOut",
72          type: spring,
73          stiffness: 1150,
74          damping: 80,
75          mass: 0.7,
76          restDelta: 0.1,
77        },
78      );
79    });
80  }
81
82  // Mobile toggle functionality
83  async toggleMobile() {
84    if (this.isToggling) return;
85    this.isToggling = true;
86    const is_open = this.mobileMenuTarget.classList.contains("hidden");
87    if (is_open) {
88      // Opening: remove hidden immediately and enable interactions
89      this.mobileMenuTarget.classList.remove("hidden");
90      this.mobileMenuTarget.style.pointerEvents = "auto"; // re-enable pointer events
91    } else {
92      // Closing: disable pointer events immediately to prevent hover interactions
93      this.mobileMenuTarget.style.pointerEvents = "none";
94    }
95    await new Promise((resolve) => requestAnimationFrame(resolve));
96    const menuPromise = this.animateMobileMenu(is_open);
97    const buttonPromise = this.rotateMobileButton(is_open);
98    await Promise.all([menuPromise, buttonPromise]);
99    if (!is_open) {
100      // Closing: add hidden once animation completes
101      this.mobileMenuTarget.classList.add("hidden");
102    }
103    this.isToggling = false;
104  }
105
106  animateMobileMenu(is_open) {
107    const menuItems = Array.from(this.mobileMenuTarget.children);
108    const properties = {
109      opacity: [is_open ? 0 : 1, is_open ? 1 : 0],
110      y: [is_open ? 20 : 0, is_open ? 0 : 10],
111      scale: [is_open ? 0.9 : 1, is_open ? 1 : 0.95],
112    };
113
114    const options = is_open
115      ? {
116          delay: stagger(0.01, { start: 0.1, from: "first" }),
117          duration: 0.3,
118          type: spring,
119          stiffness: 500,
120          damping: 25,
121          mass: 1.5,
122        }
123      : {
124          delay: stagger(0.01, { from: "last" }),
125          duration: 0.1,
126          type: spring,
127          stiffness: 400,
128          damping: 45,
129          mass: 1.5,
130        };
131
132    const animations = animate(menuItems, properties, options);
133    // If multiple elements are animated, wait for all animations to finish
134    if (Array.isArray(animations)) {
135      return Promise.all(animations.map((anim) => anim.finished));
136    }
137    return animations.finished;
138  }
139
140  rotateMobileButton(is_open) {
141    const buttonIcon = this.mobileButtonTarget.querySelector("svg");
142    if (!buttonIcon) return Promise.resolve();
143    return animate(
144      buttonIcon,
145      { rotate: is_open ? "180deg" : "0deg" },
146      {
147        duration: 2,
148        easing: "easeOut",
149        type: spring,
150        stiffness: 1150,
151        damping: 80,
152        mass: 5,
153        restDelta: 0.1,
154      },
155    ).finished;
156  }
157
158  // Initialize tooltips for each icon
159  initTooltips() {
160    this.tooltip = document.createElement("div");
161    this.tooltip.className =
162      "absolute z-50 px-1.5 py-1 text-xs font-medium text-neutral-900 bg-white border border-neutral-200/70 rounded-md shadow-sm opacity-0 dark:bg-neutral-800 dark:border-neutral-600/50 dark:text-neutral-100";
163    this.tooltip.style.position = "fixed";
164    document.body.appendChild(this.tooltip);
165
166    this.cleanupEventListeners = [];
167    this.tooltipLoopId = null;
168
169    const setupTooltipHandlers = (icon) => {
170      const mouseEnterHandler = () => {
171        this.activeIcon = icon;
172        const tooltipText = icon.dataset.tooltip;
173        const hotkey = icon.dataset.tooltipHotkey;
174        if (hotkey && hotkey.trim() !== "") {
175          this.tooltip.innerHTML = `${tooltipText} <span class="tooltip-hotkey uppercase text-[10px] font-semibold ml-0.5 px-1 py-px bg-neutral-200 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200 rounded">${hotkey}</span>`;
176        } else {
177          this.tooltip.textContent = tooltipText;
178        }
179        this.positionTooltip(icon);
180        animate(this.tooltip, { opacity: 1 }, { duration: 0.3 });
181        const updateTooltip = () => {
182          if (this.activeIcon) {
183            this.positionTooltip(this.activeIcon);
184            this.tooltipLoopId = requestAnimationFrame(updateTooltip);
185          }
186        };
187        this.tooltipLoopId = requestAnimationFrame(updateTooltip);
188      };
189
190      const mouseLeaveHandler = () => {
191        this.activeIcon = null;
192        animate(this.tooltip, { opacity: 0 }, { duration: 0.1 });
193        if (this.tooltipLoopId) {
194          cancelAnimationFrame(this.tooltipLoopId);
195          this.tooltipLoopId = null;
196        }
197      };
198
199      icon.addEventListener("mouseenter", mouseEnterHandler);
200      icon.addEventListener("mouseleave", mouseLeaveHandler);
201      this.cleanupEventListeners.push(() => {
202        icon.removeEventListener("mouseenter", mouseEnterHandler);
203        icon.removeEventListener("mouseleave", mouseLeaveHandler);
204      });
205    };
206
207    this.iconTargets.forEach(setupTooltipHandlers);
208  }
209
210  positionTooltip(icon) {
211    // Use data-tooltip-placement from the icon, defaulting to "top"
212    const placement = icon.dataset.tooltipPlacement || "top";
213    computePosition(icon, this.tooltip, {
214      placement: placement,
215      strategy: "fixed",
216      middleware: [
217        offset(8),
218        flip(),
219        shift({
220          padding: 4,
221          crossAxis: true,
222        }),
223      ],
224    }).then(({ x, y }) => {
225      Object.assign(this.tooltip.style, {
226        left: `${x}px`,
227        top: `${y}px`,
228      });
229    });
230  }
231
232  disconnect() {
233    if (this.tooltip) this.tooltip.remove();
234    if (this.tooltipLoopId) {
235      cancelAnimationFrame(this.tooltipLoopId);
236      this.tooltipLoopId = null;
237    }
238    this.cleanupEventListeners.forEach((cleanup) => cleanup());
239    document.removeEventListener("turbo:load", this.updateActiveMenuBound);
240    document.removeEventListener("turbo:load", this.restoreTooltipBound);
241  }
242
243  /**
244   * Iterates over each menu item and updates its styling based on the current URL.
245   * Both desktop and mobile menu items are updated.
246   */
247  updateActiveMenu() {
248    const desktopActiveClasses =
249      "text-neutral-50 dark:text-neutral-800 bg-neutral-800 dark:bg-neutral-100 active:bg-neutral-700 dark:active:bg-neutral-200";
250    const desktopInactiveClasses =
251      "text-neutral-500 dark:text-neutral-300 bg-neutral-200 dark:bg-neutral-800 active:bg-neutral-300 dark:active:bg-neutral-700";
252    const mobileActiveClasses =
253      "text-neutral-50 dark:text-neutral-800 hover:text-neutral-50 dark:hover:text-neutral-800 bg-neutral-800 dark:bg-neutral-100 active:bg-neutral-700 dark:active:bg-neutral-200";
254    const mobileInactiveClasses =
255      "text-neutral-500 dark:text-neutral-300 hover:text-neutral-500 dark:hover:text-neutral-300 bg-neutral-50 dark:bg-neutral-800 active:bg-neutral-100 dark:active:bg-neutral-700";
256
257    // Update desktop icons
258    this.iconTargets.forEach((anchor) => {
259      const iconBox = anchor.querySelector("div");
260      if (!iconBox) return;
261      const url = anchor.getAttribute("href");
262      try {
263        // Compare the anchor URL (resolved relative to the current origin) with the current pathname and search params.
264        const anchorUrl = new URL(url, window.location.origin);
265        // If the menu item has no search params, match only by pathname.
266        // If it has search params, match both pathname and search params exactly.
267        const isActive =
268          anchorUrl.search === ""
269            ? anchorUrl.pathname === window.location.pathname
270            : anchorUrl.pathname === window.location.pathname && anchorUrl.search === window.location.search;
271        if (isActive) {
272          iconBox.classList.remove(...desktopInactiveClasses.split(" "));
273          iconBox.classList.add(...desktopActiveClasses.split(" "));
274        } else {
275          iconBox.classList.remove(...desktopActiveClasses.split(" "));
276          iconBox.classList.add(...desktopInactiveClasses.split(" "));
277        }
278      } catch (e) {
279        // Skip if URL parsing fails.
280      }
281    });
282
283    // Update mobile menu items (if present)
284    if (this.hasMobileMenuTarget) {
285      const mobileAnchors = this.mobileMenuTarget.querySelectorAll("a");
286      mobileAnchors.forEach((anchor) => {
287        const url = anchor.getAttribute("href");
288        try {
289          const anchorUrl = new URL(url, window.location.origin);
290          // If the menu item has no search params, match only by pathname.
291          // If it has search params, match both pathname and search params exactly.
292          const isActive =
293            anchorUrl.search === ""
294              ? anchorUrl.pathname === window.location.pathname
295              : anchorUrl.pathname === window.location.pathname && anchorUrl.search === window.location.search;
296          if (isActive) {
297            anchor.classList.remove(...mobileInactiveClasses.split(" "));
298            anchor.classList.add(...mobileActiveClasses.split(" "));
299          } else {
300            anchor.classList.remove(...mobileActiveClasses.split(" "));
301            anchor.classList.add(...mobileInactiveClasses.split(" "));
302          }
303        } catch (e) {}
304      });
305    }
306  }
307
308  /**
309   * If an icon is still active (hovered) when Turbo loads a new page,
310   * re-position the tooltip and force its opacity so it remains visible.
311   */
312  restoreTooltip() {
313    if (this.activeIcon) {
314      const tooltipText = this.activeIcon.dataset.tooltip;
315      const hotkey = this.activeIcon.dataset.tooltipHotkey;
316      if (hotkey && hotkey.trim() !== "") {
317        this.tooltip.innerHTML = `${tooltipText} <span class="tooltip-hotkey uppercase text-[10px] font-semibold ml-0.5 px-1 py-px bg-neutral-200 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200 rounded">${hotkey}</span>`;
318      } else {
319        this.tooltip.textContent = tooltipText;
320      }
321      this.positionTooltip(this.activeIcon);
322      this.tooltip.style.opacity = 1;
323      if (!this.tooltipLoopId) {
324        const updateTooltipLoop = () => {
325          if (this.activeIcon) {
326            this.positionTooltip(this.activeIcon);
327            this.tooltipLoopId = requestAnimationFrame(updateTooltipLoop);
328          }
329        };
330        this.tooltipLoopId = requestAnimationFrame(updateTooltipLoop);
331      }
332    }
333  }
334};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.