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.