PageSourceSearch

https://railsblocks.com/assets/controllers/slideover_controller-d2…360606eeabe6ea17c63e1078d678bb3eb0fcdb8b.js

js railsblocks.com collected 2026-09-25 20:24:16 UTC 15,870 bytes, 447 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2
3// Shared global counter for open dialogs (modals and slideovers)
4// This ensures proper scrollbar compensation when mixing modals and slideovers
5if (!window.__openDialogCount) {
6  window.__openDialogCount = 0;
7}
8
9// Reset dialog count on page navigation to prevent desync issues
10// This handles cases where users navigate back/forward while dialogs are open
11if (!window.__dialogCountResetBound) {
12  window.__dialogCountResetBound = true;
13
14  const resetDialogCount = () => {
15    // Only reset if no dialogs are actually open
16    const openDialogs = document.querySelectorAll("dialog[open]");
17    if (openDialogs.length === 0) {
18      window.__openDialogCount = 0;
19      document.documentElement.style.removeProperty("--scrollbar-compensation");
20      document.body.classList.remove("modal-open", "slideover-open");
21    } else {
22      // Sync count with actual open dialogs
23      window.__openDialogCount = openDialogs.length;
24    }
25  };
26
27  document.addEventListener("turbo:before-cache", resetDialogCount);
28  document.addEventListener("turbo:load", resetDialogCount);
29}
30
31export default class extends Controller {
32  static targets = ["dialog", "template"];
33  static values = {
34    open: { type: Boolean, default: false }, // Whether the slideover is open
35    lazyLoad: { type: Boolean, default: false }, // Whether to lazy load the slideover content
36    turboFrameSrc: { type: String, default: "" }, // URL for the turbo frame
37    autoFocus: { type: Boolean, default: false }, // True: focus first focusable element on open. False: don't focus on anything by default. (if an element has autofocus="true", it will focus on it whether this option is true or false)
38  };
39
40  connect() {
41    if (!this.hasDialogTarget) return;
42
43    // Initialize state
44    this.contentLoaded = false;
45    this.isOpen = false; // Track if this specific slideover is open
46    this.isOpening = false; // Prevent duplicate opens while lazy content is loading
47
48    // Initialize focus trapping
49    this.focusableElements = [];
50    this.firstFocusableElement = null;
51    this.lastFocusableElement = null;
52
53    // Check if dialog is already open (from previous page state) and close it
54    if (this.dialogTarget.open) {
55      // Mark as open temporarily to allow cleanup
56      this.isOpen = true;
57      this.dialogTarget.close();
58      this.cleanupScrollbarCompensation();
59    }
60
61    // Now handle the intended open state
62    if (this.openValue) this.open();
63
64    // Set up event listeners
65    this.boundBeforeCache = this.beforeCache.bind(this);
66    this.boundBeforeVisit = this.beforeVisit.bind(this);
67    document.addEventListener("turbo:before-cache", this.boundBeforeCache);
68    document.addEventListener("turbo:before-visit", this.boundBeforeVisit);
69
70    // Add event listener for when dialog is closed by any means (including Escape key)
71    this.boundHandleDialogClose = this.handleDialogClose.bind(this);
72    this.dialogTarget.addEventListener("close", this.boundHandleDialogClose);
73
74    // Additional keydown listener for focus trapping
75    this.boundHandleKeydown = this.handleKeydown.bind(this);
76    this.dialogTarget.addEventListener("keydown", this.boundHandleKeydown);
77  }
78
79  disconnect() {
80    // If this slideover was open when disconnecting, clean up scrollbar compensation
81    if (this.isOpen) {
82      this.cleanupScrollbarCompensation();
83    }
84
85    document.removeEventListener("turbo:before-cache", this.boundBeforeCache);
86    document.removeEventListener("turbo:before-visit", this.boundBeforeVisit);
87    this.dialogTarget.removeEventListener("close", this.boundHandleDialogClose);
88    this.dialogTarget.removeEventListener("keydown", this.boundHandleKeydown);
89  }
90
91  async open(event) {
92    // If already open, don't do anything
93    if (this.isOpen || this.isOpening) return;
94
95    this.isOpening = true;
96    const triggerElement =
97      event?.currentTarget instanceof HTMLElement &&
98      event.currentTarget.matches('button, [role="button"], input[type="button"], input[type="submit"], a[href]')
99        ? event.currentTarget
100        : null;
101    const shouldShowLoadingState = this.lazyLoadValue && !this.contentLoaded;
102    let lockAcquired = false;
103
104    try {
105      // Acquire scrollbar compensation immediately so page scroll is locked
106      // before any async lazy-loading delay.
107      window.__openDialogCount++;
108      this.isOpen = true;
109      lockAcquired = true;
110
111      if (window.__openDialogCount === 1) {
112        const scrollbarWidth = this.getScrollbarWidth();
113        if (scrollbarWidth > 0) {
114          document.documentElement.style.setProperty("--scrollbar-compensation", `${scrollbarWidth}px`);
115        } else {
116          document.documentElement.style.removeProperty("--scrollbar-compensation");
117        }
118      }
119      document.body.classList.add("slideover-open");
120
121      // If lazy loading is enabled and content hasn't been loaded yet, load it now
122      if (shouldShowLoadingState) {
123        this.setTriggerLoadingState(triggerElement, true);
124        try {
125          await this.#loadTemplateContent();
126          this.contentLoaded = true;
127        } finally {
128          this.setTriggerLoadingState(triggerElement, false);
129        }
130      }
131
132      this.dialogTarget.showModal();
133
134      // Set up focus trapping
135      this.setupFocusTrapping();
136    } catch (error) {
137      // Roll back provisional lock if opening fails (e.g. lazy-load error).
138      if (lockAcquired && this.isOpen) {
139        this.cleanupScrollbarCompensation();
140      }
141      throw error;
142    } finally {
143      this.isOpening = false;
144    }
145  }
146
147  close() {
148    // If not open, don't do anything
149    if (!this.isOpen) return;
150
151    this.dialogTarget.setAttribute("closing", "");
152
153    Promise.allSettled(this.dialogTarget.getAnimations().map((animation) => animation.finished)).then(() => {
154      this.dialogTarget.removeAttribute("closing");
155      this.dialogTarget.close();
156
157      // The handleDialogClose method will handle the cleanup when the dialog actually closes
158      // This prevents double cleanup and ensures proper timing
159    });
160  }
161
162  backdropClose(event) {
163    // Only close if clicking on the actual backdrop area (outside the dialog's content box)
164    if (event.target.nodeName === "DIALOG") {
165      // Get the dialog's bounding rect to check if click is outside content area
166      const rect = this.dialogTarget.getBoundingClientRect();
167      // Click is on backdrop if it's outside the dialog's content box
168      // This prevents scrollbar clicks from being treated as backdrop clicks
169      const isBackdropClick =
170        event.clientX < rect.left ||
171        event.clientX > rect.right ||
172        event.clientY < rect.top ||
173        event.clientY > rect.bottom;
174
175      if (isBackdropClick) {
176        // Stop propagation to prevent closing parent dialogs
177        event.stopPropagation();
178
179        this.close();
180      }
181    }
182  }
183
184  show(event) {
185    this.open(event);
186  }
187
188  hide(event) {
189    if (event) event.preventDefault();
190    this.close();
191  }
192
193  beforeCache() {
194    // Close immediately without animation during navigation
195    if (this.isOpen) {
196      this.dialogTarget.removeAttribute("closing");
197      this.dialogTarget.close();
198      this.cleanupScrollbarCompensation();
199    }
200  }
201
202  beforeVisit() {
203    // Close immediately without animation during navigation
204    if (this.isOpen) {
205      this.dialogTarget.removeAttribute("closing");
206      this.dialogTarget.close();
207      this.cleanupScrollbarCompensation();
208    }
209  }
210
211  // Calculate actual scrollbar width
212  getScrollbarWidth() {
213    // Create a temporary div to measure scrollbar width
214    const outer = document.createElement("div");
215    outer.style.visibility = "hidden";
216    outer.style.overflow = "scroll";
217    outer.style.msOverflowStyle = "scrollbar"; // Force scrollbars on IE/Edge
218    document.body.appendChild(outer);
219
220    const inner = document.createElement("div");
221    outer.appendChild(inner);
222
223    const scrollbarWidth = outer.offsetWidth - inner.offsetWidth;
224    outer.parentNode.removeChild(outer);
225
226    return scrollbarWidth;
227  }
228
229  setTriggerLoadingState(triggerElement, isLoading) {
230    if (!(triggerElement instanceof HTMLElement)) return;
231
232    const supportsDisabled = "disabled" in triggerElement;
233
234    if (isLoading) {
235      triggerElement.classList.add("!cursor-wait");
236      triggerElement.setAttribute("aria-busy", "true");
237
238      if (supportsDisabled) {
239        triggerElement.dataset.overlayOriginalDisabled = triggerElement.disabled ? "true" : "false";
240        triggerElement.disabled = true;
241      } else {
242        triggerElement.dataset.overlayOriginalAriaDisabled = triggerElement.getAttribute("aria-disabled") || "";
243        triggerElement.dataset.overlayHadPointerEventsNone = triggerElement.classList.contains("pointer-events-none")
244          ? "true"
245          : "false";
246        triggerElement.setAttribute("aria-disabled", "true");
247        triggerElement.classList.add("pointer-events-none");
248      }
249
250      return;
251    }
252
253    triggerElement.classList.remove("!cursor-wait");
254    triggerElement.removeAttribute("aria-busy");
255
256    if (supportsDisabled) {
257      triggerElement.disabled = triggerElement.dataset.overlayOriginalDisabled === "true";
258      delete triggerElement.dataset.overlayOriginalDisabled;
259    } else {
260      const originalAriaDisabled = triggerElement.dataset.overlayOriginalAriaDisabled;
261      if (originalAriaDisabled) {
262        triggerElement.setAttribute("aria-disabled", originalAriaDisabled);
263      } else {
264        triggerElement.removeAttribute("aria-disabled");
265      }
266
267      if (triggerElement.dataset.overlayHadPointerEventsNone !== "true") {
268        triggerElement.classList.remove("pointer-events-none");
269      }
270
271      delete triggerElement.dataset.overlayOriginalAriaDisabled;
272      delete triggerElement.dataset.overlayHadPointerEventsNone;
273    }
274  }
275
276  async #loadTemplateContent() {
277    // Find the container in the dialog to append content to
278    const container = this.dialogTarget.querySelector("[data-slideover-content]") || this.dialogTarget;
279
280    // Check if we should use Turbo Frame lazy loading
281    if (this.turboFrameSrcValue) {
282      // Look for a turbo-frame in the container
283      let turboFrame = container.querySelector("turbo-frame");
284
285      if (!turboFrame) {
286        // Create a turbo-frame if it doesn't exist
287        turboFrame = document.createElement("turbo-frame");
288        turboFrame.id = "slideover-lazy-content";
289
290        // Clear any loading indicators or placeholder content
291        container.innerHTML = "";
292        container.appendChild(turboFrame);
293      }
294
295      // Set the src to trigger the lazy load
296      turboFrame.src = this.turboFrameSrcValue;
297
298      // Wait for the turbo-frame to load
299      return new Promise((resolve) => {
300        const handleLoad = () => {
301          turboFrame.removeEventListener("turbo:frame-load", handleLoad);
302          resolve();
303        };
304
305        turboFrame.addEventListener("turbo:frame-load", handleLoad);
306
307        // Fallback timeout in case the frame doesn't load
308        setTimeout(() => {
309          turboFrame.removeEventListener("turbo:frame-load", handleLoad);
310          resolve();
311        }, 5000);
312      });
313    } else if (this.hasTemplateTarget) {
314      // Use template-based lazy loading
315      const templateContent = this.templateTarget.content.cloneNode(true);
316
317      // Clear any loading indicators or placeholder content
318      container.innerHTML = "";
319
320      // Append the template content
321      container.appendChild(templateContent);
322    }
323  }
324
325  // Handle cleanup when dialog is closed by any means
326  handleDialogClose() {
327    // If the slideover was open, decrement the counter
328    if (this.isOpen) {
329      this.cleanupScrollbarCompensation();
330    }
331
332    // Ensure the closing attribute is removed
333    this.dialogTarget.removeAttribute("closing");
334  }
335
336  // Centralized method to handle scrollbar compensation cleanup
337  cleanupScrollbarCompensation() {
338    if (!this.isOpen) return;
339
340    window.__openDialogCount = Math.max(0, window.__openDialogCount - 1);
341    this.isOpen = false;
342
343    // Only remove scrollbar compensation if no dialogs are open
344    if (window.__openDialogCount === 0) {
345      document.documentElement.style.removeProperty("--scrollbar-compensation");
346      document.body.classList.remove("modal-open", "slideover-open");
347    } else {
348      this.syncBodyOverlayClasses();
349    }
350  }
351
352  // Keep body classes aligned with actual open overlay types when dialogs are nested
353  syncBodyOverlayClasses() {
354    const hasOpenModalDialog = document.querySelector('dialog[open][data-modal-target="dialog"]');
355    const hasOpenModalDiv = document.querySelector('[data-modal-target="dialog"].modal-open');
356    const hasOpenSlideover = document.querySelector('dialog[open][data-sli
356deover-target="dialog"]');
357
358    document.body.classList.toggle("modal-open", Boolean(hasOpenModalDialog || hasOpenModalDiv));
359    document.body.classList.toggle("slideover-open", Boolean(hasOpenSlideover));
360  }
361
362  // Handle keydown events for focus trapping
363  handleKeydown(event) {
364    // Handle Tab key for focus trapping
365    if (event.key === "Tab") {
366      this.handleTabKey(event);
367    }
368  }
369
370  // Set up focus trapping for the slideover
371  setupFocusTrapping() {
372    // Get all focusable elements within the slideover
373    this.updateFocusableElements();
374
375    // Check for elements with native autofocus attribute first
376    const autofocusElement = this.dialogTarget.querySelector("[autofocus]");
377    if (autofocusElement) {
378      // Always respect native autofocus, regardless of device type
379      autofocusElement.focus();
380    } else if (this.autoFocusValue && this.firstFocusableElement) {
381      // Fall back to auto-focusing first element if no native autofocus
382      this.firstFocusableElement.focus();
383    }
384  }
385
386  // Update the list of focusable elements
387  updateFocusableElements() {
388    const focusableSelector = [
389      "a[href]",
390      "area[href]",
391      'input:not([disabled]):not([tabindex="-1"])',
392      'button:not([disabled]):not([tabindex="-1"])',
393      'textarea:not([disabled]):not([tabindex="-1"])',
394      'select:not([disabled]):not([tabindex="-1"])',
395      "details",
396      '[tabindex]:not([tabindex="-1"])',
397      '[contenteditable]:not([contenteditable="false"])',
398    ].join(",");
399
400    this.focusableElements = Array.from(this.dialogTarget.querySelectorAll(focusableSelector)).filter((element) => {
401      // Filter out elements that are not visible or have display: none
402      return (
403        element.offsetWidth > 0 &&
404        element.offsetHeight > 0 &&
405        getComputedStyle(element).display !== "none" &&
406        getComputedStyle(element).visibility !== "hidden"
407      );
408    });
409
410    this.firstFocusableElement = this.focusableElements[0] || null;
411    this.lastFocusableElement = this.focusableElements[this.focusableElements.length - 1] || null;
412  }
413
414  // Handle Tab key for focus trapping
415  handleTabKey(event) {
416    // Update focusable elements in case the DOM has changed
417    this.updateFocusableElements();
418
419    if (this.focusableElements.length === 0) {
420      // If no focusable elements, prevent tab and keep focus on dialog
421      event.preventDefault();
422      return;
423    }
424
425    if (this.focusableElements.length === 1) {
426      // If only one focusable element, prevent tab and keep focus on it
427      event.preventDefault();
428      this.firstFocusableElement.focus();
429      return;
430    }
431
432    // Handle normal tab navigation with wrapping
433    if (event.shiftKey) {
434      // Shift+Tab: moving backwards
435      if (document.activeElement === this.firstFocusableElement) {
436        event.preventDefault();
437        this.lastFocusableElement.focus();
438      }
439    } else {
440      // Tab: moving forwards
441      if (document.activeElement === this.lastFocusableElement) {
442        event.preventDefault();
443        this.firstFocusableElement.focus();
444      }
445    }
446  }
447};

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.