PageSourceSearch

https://railsblocks.com/assets/controllers/context_menu_controller…9ca6466aee4076ab981924e779542c3454f3c93d.js

js railsblocks.com collected 2026-09-25 20:23:03 UTC 18,115 bytes, 507 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2import { computePosition, flip, shift, offset } from "@floating-ui/dom";
3
4export default class extends Controller {
5  static targets = ["menu"];
6  static values = {
7    autoClose: { type: Boolean, default: true },
8    portal: { type: Boolean, default: true },
9  };
10
11  connect() {
12    // Cache the menu before it is portaled outside the controller element.
13    this.menuElement = this.menuTarget;
14    this.originalParent = this.menuElement.parentNode;
15    this.originalNextSibling = this.menuElement.nextSibling;
16    this.isPortaled = false;
17
18    // Keep stable handler references for proper cleanup in disconnect().
19    this.boundShowMenu = this.showMenu.bind(this);
20    this.boundHandleTouchStart = this.handleTouchStart.bind(this);
21    this.boundHandleTouchEnd = this.handleTouchEnd.bind(this);
22    this.boundHandleTouchMove = this.handleTouchMove.bind(this);
23    this.boundHandleTouchCancel = this.handleTouchCancel.bind(this);
24    this.boundCloseMenu = this.closeMenu.bind(this);
25    this.boundHandleKeydown = this.handleKeydown.bind(this);
26    this.boundHandleMenuItemClick = this.handleMenuItemClick.bind(this);
27    this.boundHandleMenuAction = this.handleMenuAction.bind(this);
28
29    // JS-only iOS fix: disable native touch callout on context menu triggers.
30    this.touchCalloutElements = Array.from(
31      new Set([this.element, ...this.element.querySelectorAll(".cursor-context-menu")]),
32    ).filter((element) => element instanceof HTMLElement);
33    this.previousTouchCalloutValues = new Map();
34    this.touchCalloutElements.forEach((element) => {
35      this.previousTouchCalloutValues.set(element, element.style.webkitTouchCallout);
36      element.style.webkitTouchCallout = "none";
37    });
38
39    // Add context menu event listener
40    this.element.addEventListener("contextmenu", this.boundShowMenu);
41    // Add touch press & hold listeners for mobile with passive option
42    this.element.addEventListener("touchstart", this.boundHandleTouchStart, { passive: true });
43    this.element.addEventListener("touchend", this.boundHandleTouchEnd, { passive: true });
44    this.element.addEventListener("touchmove", this.boundHandleTouchMove, { passive: true });
45    this.element.addEventListener("touchcancel", this.boundHandleTouchCancel, { passive: true });
46    // Add click outside listener to close menu
47    document.addEventListener("click", this.boundCloseMenu);
48    // Add escape key listener
49    document.addEventListener("keydown", this.boundHandleKeydown);
50    // Add menu-item-clicked event listener
51    this.menuElement.addEventListener("menu-item-clicked", this.boundHandleMenuItemClick);
52    // Portaled menus leave the controller scope, so explicit context-menu actions need delegation.
53    this.menuElement.addEventListener("click", this.boundHandleMenuAction);
54    // Track scroll lock state
55    this.isScrollLocked = false;
56    // Touch press & hold state
57    this.touchTimer = null;
58    this.touchStartPos = null;
59    this.suppressContextMenuUntil = 0;
60    this.longPressDelay = 500; // 500ms for long press
61    this.maxTouchMovement = 10; // Max pixels allowed for movement during long press
62  }
63
64  disconnect() {
65    clearTimeout(this.closeTimeout);
66
67    // Make sure to unlock scroll when controller disconnects
68    if (this.isScrollLocked) {
69      this.unlockScroll();
70    }
71    // Clear any pending touch timer
72    if (this.touchTimer) {
73      clearTimeout(this.touchTimer);
74    }
75    this.element.removeEventListener("contextmenu", this.boundShowMenu);
76    this.element.removeEventListener("touchstart", this.boundHandleTouchStart);
77    this.element.removeEventListener("touchend", this.boundHandleTouchEnd);
78    this.element.removeEventListener("touchmove", this.boundHandleTouchMove);
79    this.element.removeEventListener("touchcancel", this.boundHandleTouchCancel);
80    document.removeEventListener("click", this.boundCloseMenu);
81    document.removeEventListener("keydown", this.boundHandleKeydown);
82    this.menuElement.removeEventListener("menu-item-clicked", this.boundHandleMenuItemClick);
83    this.menuElement.removeEventListener("click", this.boundHandleMenuAction);
84
85    // Restore previous inline touch-callout styles.
86    if (this.touchCalloutElements && this.previousTouchCalloutValues) {
87      this.touchCalloutElements.forEach((element) => {
88        element.style.webkitTouchCallout = this.previousTouchCalloutValues.get(element) || "";
89      });
90    }
91
92    this.#restoreMenuMount();
93  }
94
95  handleTouchStart(event) {
96    // Only handle single finger touches
97    if (event.touches.length !== 1) {
98      this.cancelTouchTimer();
99      return;
100    }
101
102    // Store the initial touch position
103    const touch = event.touches[0];
104    const touchX = touch.clientX;
105    const touchY = touch.clientY;
106    this.touchStartPos = {
107      x: touchX,
108      y: touchY,
109    };
110
111    // Start the long press timer
112    this.touchTimer = setTimeout(() => {
113      // iOS Safari may emit a native contextmenu event right after long-press.
114      // Suppress it briefly to avoid duplicate open/repaint sequences.
115      this.suppressContextMenuUntil = Date.now() + 750;
116      // Show our custom context menu
117      this.showMenuAtPosition(touchX, touchY, { source: "touch" });
118      this.touchTimer = null;
119    }, this.longPressDelay);
120  }
121
122  handleTouchMove(event) {
123    // If we don't have a timer running, ignore
124    if (!this.touchTimer || !this.touchStartPos) {
125      return;
126    }
127
128    // Check if the finger has moved too far
129    const touch = event.touches[0];
130    const deltaX = Math.abs(touch.clientX - this.touchStartPos.x);
131    const deltaY = Math.abs(touch.clientY - this.touchStartPos.y);
132    const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
133
134    // Cancel long press if moved too far
135    if (distance > this.maxTouchMovement) {
136      this.cancelTouchTimer();
137    }
138  }
139
140  handleTouchEnd(event) {
141    this.cancelTouchTimer();
142  }
143
144  handleTouchCancel(event) {
145    this.cancelTouchTimer();
146  }
147
148  cancelTouchTimer() {
149    if (this.touchTimer) {
150      clearTimeout(this.touchTimer);
151      this.touchTimer = null;
152    }
153    this.touchStartPos = null;
154  }
155
156  showMenu(event) {
157    if (Date.now() < this.suppressContextMenuUntil) {
158      event.preventDefault();
159      return;
160    }
161
162    event.preventDefault();
163    this.showMenuAtPosition(event.clientX, event.clientY, { source: "contextmenu" });
164  }
165
166  async showMenuAtPosition(clientX, clientY, { source = "programmatic" } = {}) {
167    clearTimeout(this.closeTimeout);
168    this.closeTimeout = null;
169
170    const anchorX = Number.isFinite(clientX) ? clientX : 0;
171    const anchorY = Number.isFinite(clientY) ? clientY : 0;
172
173    // Close any other open context menus first
174    this.application.controllers
175      .filter((controller) => controller.identifier === "context-menu" && controller !== this && controller.isOpen)
176      .forEach((controller) => controller.close());
177
178    // Close all open dropdown popovers
179    this.application.controllers
180      .filter((controller) => controller.identifier === "dropdown-popover" && controller.isOpen)
181      .forEach((controller) => controller.close());
182
183    this.lockScroll(source);
184    this.#mountMenuForOpen();
185
186    if (!this.menuElement.open) {
187      this.menuElement.show();
188    }
189
190    // Create a virtual element at the cursor/touch position for Floating UI
191    const virtualElement = {
192      getBoundingClientRect() {
193        return {
194          width: 0,
195          height: 0,
196          x: anchorX,
197          y: anchorY,
198          top: anchorY,
199          left: anchorX,
200          right: anchorX,
201          bottom: anchorY,
202        };
203      },
204    };
205
206    // Keep the menu hidden at a safe position while measuring to avoid Safari scroll jumps.
207    const previousVisibility = this.menuElement.style.visibility;
208    this.menuElement.style.visibility = "hidden";
209    this.menuElement.style.left = "0px";
210    this.menuElement.style.top = "0px";
211
212    try {
213      // Use Floating UI to compute the optimal position
214      const { x, y } = await computePosition(virtualElement, this.menuElement, {
215        strategy: "fixed",
216        placement: "bottom-start",
217        middleware: [
218          offset(4), // Small offset from cursor
219          flip({
220            fallbackPlacements: ["top-start", "bottom-end", "top-end"],
221          }),
222          shift({ padding: 8 }), // Keep 8px padding from window edges
223        ],
224      });
225
226      // Apply the computed position
227      this.menuElement.style.left = `${x}px`;
228      this.menuElement.style.top = `${y}px`;
229    } catch (_error) {
230      // Fallback to the click/touch location if positioning fails.
231      this.menuElement.style.left = `${anchorX}px`;
232      this.menuElement.style.top = `${anchorY}px`;
233    }
234
235    // Add animation classes after a frame
236    requestAnimationFrame(() => {
237      this.menuElement.style.visibility = previousVisibility;
238      this.menuElement.classList.add("[&[open]]:scale-100", "[&[open]]:opacity-100");
239      // Focus the menu element itself instead of any items
240      this.#focusWithoutScroll(this.menuElement);
241    });
242  }
243
244  get isOpen() {
245    return Boolean(this.menuElement?.open);
246  }
247
248  #mountMenuForOpen() {
249    if (!this.portalValue) return;
250
251    const appendTarget = this.element.closest("dialog[open]") || document.body;
252    if (this.menuElement.parentElement !== appendTarget) {
253      appendTarget.appendChild(this.menuElement);
254    }
255    this.isPortaled = this.menuElement.parentElement !== this.originalParent;
256  }
257
258  #restoreMenuMount() {
259    if (!this.isPortaled || !this.originalParent) return;
260
261    if (this.originalNextSibling && this.originalNextSibling.parentNode === this.originalParent) {
262      this.originalParent.insertBefore(this.menuElement, this.originalNextSibling);
263    } else {
264      this.originalParent.appendChild(this.menuElement);
265    }
266
267    this.isPortaled = false;
268  }
269
270  closeMenu(event) {
271    if (!this.isOpen) return;
272
273    if (!this.menuElement.contains(event.target)) {
274      this.#closeContextMenu();
275    }
276  }
277
278  handleKeydown(event) {
279    if (event.key === "Escape" && this.menuElement.open) {
280      this.#closeContextMenu();
281    }
282  }
283
284  handleMenuItemClick(event) {
285    if (this.autoCloseValue) {
286      this.#closeContextMenu();
287    }
288  }
289
290  handleMenuAction(event) {
291    if (this.element.contains(this.menuElement)) return;
292    if (!(event.target instanceof Element)) return;
293
294    const actionElement = event.target.closest("[data-action]");
295    if (!actionElement || !this.menuElement.contains(actionElement)) return;
296
297    const closestContextMenu = actionElement.closest('[data-controller~="context-menu"]');
298    if (closestContextMenu && closestContextMenu !== this.element) return;
299
300    const actionNames = this.#localContextMenuActionNamesFor(event);
301    if (actionNames.length === 0) return;
302
303    actionNames.forEach((actionName) => {
304      event.preventDefault();
305      this[actionName]();
306    });
307  }
308
309  // Public method to force close the menu (can be called via data-action)
310  close() {
311    this.#closeContextMenu();
312  }
313
314  #localContextMenuActionNamesFor(event) {
315    const handledActions = new Set(["close"]);
316    if (!(event.target instanceof Element)) return [];
317
318    const actionElement = event.target.closest("[data-action]");
319    if (!actionElement || !this.menuElement.contains(actionElement)) return [];
320
321    return (actionElement.getAttribute("data-action") || "")
322      .split(/\s+/)
323      .map((descriptor) => {
324        const match = descriptor.match(/^(?:(?:click)(?:\.[^@-]+)?->)?context-menu#([A-Za-z0-9_]+)$/);
325        return match?.[1];
326      })
327      .filter((actionName) => handledActions.has(actionName));
328  }
329
330  #focusWithoutScroll(element) {
331    if (!element || typeof element.focus !== "function") return;
332
333    try {
334      element.focus({ preventScroll: true });
335    } catch (_error) {
336      element.focus();
337    }
338  }
339
340  // Private method to handle all context menu closing logic
341  #closeContextMenu() {
342    if (!this.isOpen && !this.closeTimeout) return;
343
344    clearTimeout(this.closeTimeout);
345    this.closeTimeout = null;
346
347    this.unlockScroll();
348
349    // Reset focus states in the menu
350    const menuController = this.application.getControllerForElementAndIdentifier(this.menuElement, "menu");
351    if (menuController) {
352      menuController.reset();
353    }
354
355    // Close all nested dropdowns immediately (no animation) and remove state attributes
356    const nestedDropdowns = this.menuElement.querySelectorAll('[data-controller~="dropdown-popover"]');
357    nestedDropdowns.forEach((dropdown) => {
358      const controller = this.application.getControllerForElementAndIdentifier(dropdown, "dropdown-popover");
359      if (controller && controller.isOpen) {
360        controller.buttonTarget.removeAttribute("data-state");
361        // Use closeImmediate for faster closing of nested menus
362        if (typeof controller.closeImmediate === "function") {
363          controller.closeImmediate();
364        } else {
365          controller.close();
366        }
367      }
368    });
369
370    // Then close the main context menu with animation
371    this.menuElement.classList.remove("[&[open]]:scale-100", "[&[open]]:opacity-100");
372    this.closeTimeout = setTimeout(() => {
373      if (this.menuElement.open) {
374        this.menuElement.close();
375      }
376      this.#restoreMenuMount();
377      this.closeTimeout = null;
378    }, 100);
379  }
380
381  // Add these new methods for scroll locking
382  lockScroll(source = "programmatic") {
383    if (this.isScrollLocked) return;
384
385    this.scrollLockTarget = this.#getScrollLockTarget();
386
387    // For parent overlays, block user-driven scroll events without mutating
388    // overflow styles so scrollbar layout stays stable.
389    if (this.scrollLockTarget !== document.body) {
390      this.previousTargetOverscrollBehavior = this.scrollLockTarget.style.overscrollBehavior;
391      this.scrollLockTarget.style.overscrollBehavior = "contain";
392      this.lockedTargetScrollTop = this.scrollLockTarget.scrollTop;
393      this.lockedTargetScrollLeft = this.scrollLockTarget.scrollLeft;
394
395      this.boundPreventTargetScrollInput = this.#preventTargetScrollInput.bind(this);
396      this.boundRestoreTargetScrollPosition = this.#restoreTargetScrollPosition.bind(this);
397
398      this.scrollLockTarget.addEventListener("wheel", this.boundPreventTargetScrollInput, {
399        passive: false,
400        capture: true,
401      });
402      this.scrollLockTarget.addEventListener("touchmove", this.boundPreventTargetScrollInput, {
403        passive: false,
404        capture: true,
405      });
406      this.scrollLockTarget.addEventListener("scroll", this.boundRestoreTargetScrollPosition, { passive: true });
407
408      this.isOverlayScrollLocked = true;
409      this.isScrollLocked = true;
410      return;
411    }
412
413    // On iOS Safari, body locking during long-press can trigger brief black repaints.
414    // Skip body-level touch locking for touch-origin opens.
415    if (source === "touch" && this.scrollLockTarget === document.body) {
416      return;
417    }
418
419    // Store current scroll position
420    this.scrollPosition = window.pageYOffset || document.documentElement.scrollTop;
421
422    // Store current body styles
423    this.previousBodyPosition = document.body.style.position;
424    this.previousBodyTop = document.body.style.top;
425    this.previousBodyWidth = document.body.style.width;
426    this.previousBodyPointerEvents = document.body.style.pointerEvents;
427
428    // Lock scroll by fixing the body position
429    document.body.style.position = "fixed";
430    document.body.style.top = `-${this.scrollPosition}px`;
431    document.body.style.width = "100%";
432
433    // Disable pointer events on the body (but not the menu)
434    document.body.style.pointerEvents = "none";
435    this.menuElement.style.pointerEvents = "auto";
436
437    this.isScrollLocked = true;
438  }
439
440  unlockScroll() {
441    if (!this.isScrollLocked) return;
442
443    if (this.scrollLockTarget && this.scrollLockTarget !== document.body) {
444      this.scrollLockTarget.style.overscrollBehavior = this.previousTargetOverscrollBehavior || "";
445
446      if (this.boundPreventTargetScrollInput) {
447        this.scrollLockTarget.removeEventListener("wheel", this.boundPreventTargetScrollInput, { capture: true });
448        this.scrollLockTarget.removeEventListener("touchmove", this.boundPreventTargetScrollInput, { capture: true });
449      }
450
451      if (this.boundRestoreTargetScrollPosition) {
452        this.scrollLockTarget.removeEventListener("scroll", this.boundRestoreTargetScrollPosition);
453      }
454
455      this.boundPreventTargetScrollInput = null;
456      this.boundRestoreTargetScrollPosition = null;
457      this.isOverlayScrollLocked = false;
458      this.isScrollLocked = false;
459      this.scrollLockTarget = null;
460      return;
461    }
462
463    // Restore previous body styles
464    document.body.style.position = this.previousBodyPosition;
465    document.body.style.top = this.previousBodyTop;
466    document.body.style.width = this.previousBodyWidth;
467    document.body.style.pointerEvents = this.previousBodyPointerEvents;
468
469    // Restore scroll position
470    window.scrollTo(0, this.scrollPosition);
471
472    this.isScrollLocked = false;
473    this.scrollLockTarget = null;
474  }
475
476  #preventTargetScrollInput(event) {
477    if (!this.isOverlayScrollLocked || !this.scrollLockTarget) return;
478
479    // Allow interaction inside the context menu itself.
480    if (this.menuElement.contains(event.target)) return;
481
482    event.preventDefault();
483  }
484
485  #restoreTargetScrollPosition() {
486    if (!this.isOverlayScrollLocked || !this.scrollLockTarget) return;
487
488    if (this.scrollLockTarget.scrollTop !== this.lockedTargetScrollTop) {
489      this.scrollLockTarget.scrollTop = this.lockedTargetScrollTop;
490    }
491    if (this.scrollLockTarget.scrollLeft !== this.lockedTargetScrollLeft) {
492      this.scrollLockTarget.scrollLeft = this.lockedTargetScrollLeft;
493    }
494  }
495
496  #getScrollLockTarget() {
497    const parentOverlay = this.element.closest(
498      'dialog[open][data-modal-target="dialog"], dialog[open][data-slideover-target="dialog"], dialog[open][data-drawer-target="dialog"], dialog[open], .modal-div.modal-open',
499    );
500
501    if (parentOverlay instanceof HTMLElement) {
502      return parentOverlay;
503    }
504
505    return document.body;
506  }
507};

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.