1import { Controller } from "@hotwired/stimulus"; 2 3const DRAWER_EASE = "cubic-bezier(0.35, 0.75, 0, 1)"; 4const DEFAULT_OVERLAY_ENTER_DURATION_MS = 450; // Overlay fade-in duration used when opening the drawer. 5const DEFAULT_OVERLAY_EXIT_DURATION_MS = 300; // Overlay fade-out duration used when closing the drawer. 6const DRAWER_SNAP_DURATION_MS = 300; // Default transform animation duration when snapping to a target state/snap point. 7const DRAWER_BOUNCE_IN_DURATION_MS = 150; // Quick "settle" animation duration for subtle bounce-in feedback. 8const DRAWER_BOUNCE_OUT_DURATION_MS = 250; // Bounce-out animation duration when the drawer eases back after overdrag. 9const DRAWER_MAX_OVERLAY_OPACITY = 0.5; // Highest backdrop opacity allowed while the drawer is open. 10const DRAWER_DRAG_DAMPING_FACTOR = 0.24; // Resistance factor applied to drag movement to dampen gesture translation. 11const DRAWER_MAX_OVERDRAG_PX = 36; // Maximum pixels the drawer can be pulled beyond bounds before clamping. 12const DRAWER_STEP_SNAP_THRESHOLD_PX = 14; // Pixel distance from a snap point under which we auto-snap to that step. 13 14// Shared global counter for open dialogs (modals, slideovers, and drawers) 15if (!window.__openDialogCount) { 16 window.__openDialogCount = 0; 17} 18 19// Reset dialog count on page navigation to prevent desync issues 20// This handles cases where users navigate back/forward while dialogs are open 21if (!window.__dialogCountResetBound) { 22 window.__dialogCountResetBound = true; 23 24 const resetDialogCount = () => { 25 // Only reset if no dialogs are actually open 26 const openDialogs = document.querySelectorAll("dialog[open]"); 27 if (openDialogs.length === 0) { 28 window.__openDialogCount = 0; 29 document.documentElement.style.removeProperty("--scrollbar-compensation"); 30 document.body.classList.remove("modal-open", "slideover-open", "drawer-open"); 31 } else { 32 // Sync count with actual open dialogs 33 window.__openDialogCount = openDialogs.length; 34 } 35 }; 36 37 document.addEventListener("turbo:before-cache", resetDialogCount); 38 document.addEventListener("turbo:load", resetDialogCount); 39} 40 41export default class extends Controller { 42 static targets = ["dialog", "handle", "snapIndicator", "template", "snapContent"]; 43 static values = { 44 open: { type: Boolean, default: false }, 45 snapPoints: { type: Array, default: ["180px", "80%"] }, // Height positions: ["180px" = 180px tall, "50%" = half screen height] 46 activeSnapPoint: { type: Number, default: 0 }, 47 dismissible: { type: Boolean, default: true }, 48 fadeFromIndex: { type: Number, default: -1 }, // how this works: -1 means no fade, 0 means fade from the first snap point, 1 means fade from the second snap point, etc. 49 closeThreshold: { type: Number, default: 0.25 }, // Percentage of drawer height to trigger close 50 velocityThreshold: { type: Number, default: 0.5 }, // Velocity threshold for flick gestures 51 scrollLockTimeout: { type: Number, default: 100 }, // Time after scroll top hit during which drag close is blocked 52 respectReducedMotion: { type: Boolean, default: true }, // Disable to force animations for debugging 53 lazyLoad: { type: Boolean, default: false }, // Whether to lazy load the drawer content 54 turboFrameSrc: { type: String, default: "" }, // URL for the turbo frame 55 }; 56 57 connect() { 58 this.boundBeforeCache = this.beforeCache.bind(this); 59 document.addEventListener("turbo:before-cache", this.boundBeforeCache); 60 61 this.isOpen = false; 62 this.isOpening = false; // Add isOpening flag 63 this.isDragging = false; 64 this.isBouncing = false; 65 this.isClosing = false; // Add flag to track closing state 66 this.isLoadingContent = false; // Prevent duplicate lazy-load requests before first open 67 this.closeTimeout = null; 68 this.contentLoaded = false; // Track if lazy content has been loaded 69 this.activePointerId = null; 70 this.pointerCaptureElement = null; 71 this.previousBodyCursor = null; 72 this.openTime = 0; 73 this.lastTimeDragPrevented = 0; 74 this.isAllowedToDrag = false; 75 this.dragSnapPointTransforms = null; 76 this.dragFrame = null; 77 this.pendingDragTransform = null; 78 this.snapHeightSyncFrame = null; 79 this.snapHeightSyncTimeout = null; 80 this.snapHeightSyncTransitionEnd = null; 81 this.focusTimeout = null; 82 this.openFrame = null; 83 this.openCompletionTimeout = null; 84 this.autoHeightFrame = null; 85 this.startY = 0; 86 this.currentY = 0; 87 this.lastY = 0; 88 this.velocity = 0; 89 this.lastTime = Date.now(); 90 91 // Initialize focus trapping 92 this.focusableElements = []; 93 this.firstFocusableElement = null; 94 this.lastFocusableElement = null; 95 this.contentWrapper = this.dialogTarget.querySelector(":scope > div"); 96 this.lastContentWrapperHeight = null; 97 98 // Bind event handlers once 99 this.boundHandleStart = this.handleStart.bind(this); 100 this.boundHandleMove = this.handleMove.bind(this); 101 this.boundHandleEnd = this.handleEnd.bind(this); 102 this.boundHandleCancel = this.handleCancel.bind(this); 103 this.boundHandleReducedMotionChange = this.handleReducedMotionChange.bind(this); 104 this.boundHandleWindowBlur = this.handleWindowBlur.bind(this); 105 106 this.reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); 107 this.isReducedMotion = this.respectReducedMotionValue && this.reducedMotionQuery.matches; 108 this.drawerOpenDurationMs = this.readCssDuration( 109 "--overlay-motion-duration-enter",
110 DEFAULT_OVERLAY_ENTER_DURATION_MS, 111 ); 112 this.drawerCloseDurationMs = this.readCssDuration( 113 "--overlay-motion-duration-exit", 114 DEFAULT_OVERLAY_EXIT_DURATION_MS, 115 ); 116 if (this.reducedMotionQuery.addEventListener) { 117 this.reducedMotionQuery.addEventListener("change", this.boundHandleReducedMotionChange); 118 } else { 119 this.reducedMotionQuery.addListener(this.boundHandleReducedMotionChange); 120 } 121 122 // Add event listener for dialog close 123 this.boundHandleDialogClose = this.handleDialogClose.bind(this); 124 this.dialogTarget.addEventListener("close", this.boundHandleDialogClose); 125 126 // Add event listener for escape key 127 this.boundHandleEscape = this.handleEscape.bind(this); 128 this.dialogTarget.addEventListener("cancel", this.boundHandleEscape); 129 130 // Additional keydown listener for better escape key handling 131 this.boundHandleKeydown = this.handleKeydown.bind(this); 132 this.dialogTarget.addEventListener("keydown", this.boundHandleKeydown); 133 134 // Add resize observer for auto snap points 135 this.resizeObserver = new ResizeObserver(() => { 136 if (this.isOpen && this.snapPointsValue[this.activeSnapPointValue] === "auto") { 137 // Debounce resize recalculations 138 clearTimeout(this.resizeTimeout); 139 this.resizeTimeout = setTimeout(() => { 140 this.updateAutoHeight(); 141 }, 100); 142 } 143 }); 144 145 // Add mutation observer to watch for content changes 146 this.mutationObserver = new MutationObserver(() => { 147 if (this.isOpen && !this.isClosing && this.snapPointsValue[this.activeSnapPointValue] === "auto") { 148 // Debounce mutation recalculations 149 clearTimeout(this.mutationTimeout); 150 this.mutationTimeout = setTimeout(() => { 151 this.updateAutoHeight(); 152 }, 100); 153 } 154 }); 155 156 // Observe the dialog content for changes 157 this.resizeObserver.observe(this.dialogTarget); 158 159 // Start observing mutations (attributes, childList, and subtree) 160 this.mutationObserver.observe(this.dialogTarget, { 161 attributes: true, 162 childList: true, 163 subtree: true, 164 attributeFilter: ["style", "class", "hidden", "data-state"], // Watch for common state changes 165 }); 166 167 // Also listen to window resize for viewport changes 168 this.boundHandleResize = this.handleResize.bind(this); 169 window.addEventListener("resize", this.boundHandleResize); 170 window.addEventListener("blur", this.boundHandleWindowBlur); 171 172 // Set initial styles 173 this.dialogTarget.style.transform = "translate3d(0, 100%, 0)"; 174 this.dialogTarget.style.transition = "none"; 175 if (this.hasHandleTarget) { 176 // Let the drawer own vertical touch gestures on the handle to avoid spurious pointercancel events. 177 this.handleTarget.style.touchAction = "none"; 178 } 179 180 // Open if specified in data attribute (after all initialization is complete) 181 if (this.openValue) { 182 // Use requestAnimationFrame to ensure DOM is ready 183 requestAnimationFrame(() => { 184 this.open(); 185 }); 186 } 187 } 188 189 disconnect() { 190 if (this.reducedMotionQuery?.removeEventListener) { 191 this.reducedMotionQuery.removeEventListener("change", this.boundHandleReducedMotionChange); 192 } else if (this.reducedMotionQuery?.removeListener) { 193 this.reducedMotionQuery.removeListener(this.boundHandleReducedMotionChange); 194 } 195 196 if (this.closeTimeout) { 197 clearTimeout(this.closeTimeout); 198 } 199 this.cancelDeferredMotionWork(); 200 this.clearPendingFocusTimeout(); 201 if (this.dragFrame !== null) { 202 cancelAnimationFrame(this.dragFrame); 203 this.dragFrame = null; 204 } 205 this.stopSnapHeightSync(); 206 this.releaseActivePointerCapture(); 207 this.clearDraggingCursor(); 208 209 if (this.isOpen) { 210 window.__openDialogCount = Math.max(0, window.__openDialogCount - 1); 211 this.isOpen = false; 212 213 if (window.__openDialogCount === 0) { 214 document.documentElement.style.removeProperty("--scrollbar-compensation"); 215 document.body.classList.remove("modal-open", "slideover-open", "drawer-open"); 216 } else {
217 this.syncBodyOverlayClasses(); 218 } 219 } 220 221 document.removeEventListener("turbo:before-cache", this.boundBeforeCache); 222 this.dialogTarget.removeEventListener("close", this.boundHandleDialogClose); 223 this.dialogTarget.removeEventListener("cancel", this.boundHandleEscape); 224 this.dialogTarget.removeEventListener("keydown", this.boundHandleKeydown); 225 window.removeEventListener("resize", this.boundHandleResize); 226 window.removeEventListener("blur", this.boundHandleWindowBlur); 227 228 // Clean up observers 229 if (this.resizeObserver) { 230 this.resizeObserver.disconnect(); 231 } 232 233 if (this.mutationObserver) { 234 this.mutationObserver.disconnect(); 235 } 236 237 // Clear any pending timeouts 238 if (this.resizeTimeout) { 239 clearTimeout(this.resizeTimeout); 240 } 241 242 if (this.mutationTimeout) { 243 clearTimeout(this.mutationTimeout); 244 } 245 246 this.removeEventListeners(); 247 this.dragSnapPointTransforms = null; 248 } 249 250 handleReducedMotionChange(event) { 251 this.isReducedMotion = this.respectReducedMotionValue && event.matches; 252 } 253 254 getDuration(durationMs) { 255 return this.isReducedMotion ? 0 : durationMs; 256 } 257 258 readCssDuration(variableName, fallbackMs) { 259 const rawValue = getComputedStyle(document.documentElement).getPropertyValue(variableName).trim(); 260 if (!rawValue) return fallbackMs; 261 262 if (rawValue.endsWith("ms")) { 263 const parsed = parseFloat(rawValue); 264 return Number.isFinite(parsed) ? parsed : fallbackMs; 265 } 266 267 if (rawValue.endsWith("s")) { 268 const parsed = parseFloat(rawValue); 269 return Number.isFinite(parsed) ? parsed * 1000 : fallbackMs; 270 } 271 272 return fallbackMs; 273 } 274 275 getTransformTransition(durationMs) { 276 const duration = this.getDuration(durationMs); 277 if (duration === 0) return "none"; 278 return `transform ${duration}ms ${DRAWER_EASE}`; 279 } 280 281 translateY(value) { 282 const y = typeof value === "number" ? `${value}px` : value; 283 this.dialogTarget.style.transform = `translate3d(0, ${y}, 0)`; 284 } 285 286 flushPendingDragVisualUpdate() { 287 if (this.pendingDragTransform === null) return; 288 289 const pendingTransform = this.pendingDragTransform; 290 this.translateY(pendingTransform); 291 this.updateScrollableHeight(this.getVisibleDrawerHeight(pendingTransform)); 292 293 this.pendingDragTransform = null; 294 } 295 296 scheduleDragVisualUpdate(transform) { 297 this.pendingDragTransform = transform; 298 299 if (this.dragFrame !== null) return; 300 301 this.dragFrame = requestAnimationFrame(() => { 302 this.dragFrame = null; 303 this.flushPendingDragVisualUpdate(); 304 }); 305 } 306 307 cancelDeferredMotionWork() { 308 if (this.openCompletionTimeout !== null) { 309 clearTimeout(this.openCompletionTimeout); 310 this.openCompletionTimeout = null; 311 } 312 313 if (this.openFrame !== null) { 314 cancelAnimationFrame(this.openFrame); 315 this.openFrame = null; 316 } 317 318 if (this.autoHeightFrame !== null) { 319 cancelAnimationFrame(this.autoHeightFrame); 320 this.autoHeightFrame = null; 321 } 322 } 323 324 stopSnapHeightSync() { 325 if (this.snapHeightSyncFrame !== null) { 326 cancelAnimationFrame(this.snapHeightSyncFrame); 327 this.snapHeightSyncFrame = null; 328 } 329 330 if (this.snapHeightSyncTimeout !== null) { 331 clearTimeout(this.snapHeightSyncTimeout); 332 this.snapHeightSyncTimeout = null; 333 } 334 335 if (this.snapHeightSyncTransitionEnd) { 336 this.dialogTarget.removeEventListener("transitionend", this.snapHeightSyncTransitionEnd); 337 this.snapHeightSyncTransitionEnd = null; 338 } 339 } 340 341 syncScrollableHeightDuringSnap(transitionDuration) { 342 this.stopSnapHeightSync(); 343 344 const finalizeSync = () => { 345 this.stopSnapHeightSync(); 346 this.updateScrollableHeight(); 347 }; 348 349 const tick = () => { 350 this.updateScrollableHeight(); 351 this.snapHeightSyncFrame = requestAnimationFrame(tick); 352 }; 353 354 this.snapHeightSyncFrame = requestAnimationFrame(tick); 355 356 this.snapHeightSyncTransitionEnd = (event) => { 357 if (event.target !== this.dialogTarget || event.propertyName !== "transform") return; 358 finalizeSync(); 359 }; 360 this.dialogTarget.addEventListener("transitionend", this.snapHeightSyncTransitionEnd); 361 362 const fallbackDelayMs = Math.max(transitionDuration + 50, 100); 363 this.snapHeightSyncTimeout = setTimeout(() => { 364 finalizeSync(); 365 }, fallbackDelayMs); 366 } 367 368 queueFinishClosing(durationMs) { 369 if (this.closeTimeout) { 370 clearTimeout(this.closeTimeout); 371 } 372 373 if (durationMs === 0) { 374 this.finishClosing(); 375 return; 376 } 377 378 this.closeTimeout = setTimeout(() => { 379 this.finishClosing(); 380 }, durationMs); 381 } 382 383 clamp(value, min, max) { 384 return Math.min(max, Math.max(min, value)); 385 } 386 387 getMaxSnapIndex() { 388 return Math.max(0, this.snapPointsValue.length - 1); 389 } 390 391 getSnapStepFromVelocity(velocity) { 392 const safeThreshold = Math.max(this.velocityThresholdValue, 0.01); 393 const normalizedVelocity = velocity / safeThreshold; 394 if (normalizedVelocity < 1.5) return 1; 395 if (normalizedVelocity < 2.5) return 2; 396 return 3; 397 } 398 399 dampenDistance(distance) { 400 const dampedDistance = distance * DRAWER_DRAG_DAMPING_FACTOR; 401 return (DRAWER_MAX_OVERDRAG_PX * dampedDistance) / (dampedDistance + DRAWER_MAX_OVERDRAG_PX); 402 } 403 404 getSnapPointTransformY(index) { 405 const cachedTransform = this.dragSnapPointTransforms?.[index]; 406 if (typeof cachedTransform === "number") { 407 return cachedTransform; 408 } 409 410 const snapPoint = this.snapPointsValue[index]; 411 if (!snapPoint || !this.dialogTarget) return null; 412 413 const dialogHeight = this.dialogTarget.offsetHeight; 414 if (!dialogHeight) return null; 415 416 if (snapPoint === "auto") { 417 // Auto snap points are measured dynamically. When active, the live transform is the most reliable value. 418 if (index === this.activeSnapPointValue) { 419 return this.getCurrentTransformY(); 420 } 421 422 const autoTranslate = this.calculateAutoTranslateY(); 423 const match = autoTranslate.match(/calc\(100%\s*-\s*([0-9.]+)px\)/); 424 if (match) { 425 return this.clamp(dialogHeight - parseFloat(match[1]), 0, dialogHeight); 426 } 427 return null; 428 } 429 430 if (typeof snapPoint === "string" && snapPoint.endsWith("px")) { 431 return this.clamp(dialogHeight - parseFloat(snapPoint), 0, dialogHeight); 432 } 433 434 if (typeof snapPoint === "string" && snapPoint.endsWith("%")) { 435 const percentage = parseFloat(snapPoint) / 100; 436 const pixelHeight = window.innerHeight * percentage; 437 return this.clamp(dialogHeight - pixelHeight, 0, dialogHeight); 438 } 439 440 return null; 441 } 442 443 captureDragSnapPointTransforms() { 444 if (!this.snapPointsValue?.length) { 445 this.dragSnapPointTransforms = null; 446 return; 447 } 448 449 const currentTransformY = this.getCurrentTransformY(); 450 this.dragSnapPointTransforms = this.snapPointsValue.map((_snapPoint, index) => { 451 if (index === this.activeSnapPointValue) { 452 return currentTransformY; 453 }
454 return this.getSnapPointTransformY(index); 455 }); 456 } 457 458 trySnapToAdjacentPointDuringDrag(clientY, proposedTransform, deltaY) { 459 const maxSnapIndex = this.getMaxSnapIndex(); 460 const currentSnapIndex = this.activeSnapPointValue; 461 const upperAdjacentIndex = Math.min(currentSnapIndex + 1, maxSnapIndex); 462 const lowerAdjacentIndex = Math.max(currentSnapIndex - 1, 0); 463 464 const clearPendingDragUpdate = () => { 465 if (this.dragFrame !== null) { 466 cancelAnimationFrame(this.dragFrame); 467 this.dragFrame = null; 468 } 469 this.pendingDragTransform = null; 470 }; 471 472 if (deltaY < 0 && upperAdjacentIndex !== currentSnapIndex) { 473 const upperTransform = this.getSnapPointTransformY(upperAdjacentIndex); 474 if (upperTransform !== null && proposedTransform < upperTransform - DRAWER_STEP_SNAP_THRESHOLD_PX) { 475 clearPendingDragUpdate(); 476 this.applySnapPointState(upperAdjacentIndex); 477 const shouldRubberBandAtTopEdge = upperAdjacentIndex === maxSnapIndex && proposedTransform < upperTransform; 478 const handoffTransform = shouldRubberBandAtTopEdge 479 ? upperTransform - this.dampenDistance(upperTransform - proposedTransform) 480 : proposedTransform; 481 482 this.initialTransform = handoffTransform; 483 this.startY = clientY; 484 this.currentY = clientY; 485 this.lastY = clientY; 486 this.translateY(handoffTransform); 487 this.updateScrollableHeight(this.getVisibleDrawerHeight(handoffTransform)); 488 return true; 489 } 490 } 491 492 if (deltaY > 0 && lowerAdjacentIndex !== currentSnapIndex) { 493 const lowerTransform = this.getSnapPointTransformY(lowerAdjacentIndex); 494 if (lowerTransform !== null && proposedTransform > lowerTransform + DRAWER_STEP_SNAP_THRESHOLD_PX) { 495 clearPendingDragUpdate(); 496 this.applySnapPointState(lowerAdjacentIndex); 497 const shouldRubberBandAtBottomEdge = 498 lowerAdjacentIndex === 0 && !this.dismissibleValue && proposedTransform > lowerTransform; 499 const handoffTransform = shouldRubberBandAtBottomEdge 500 ? lowerTransform + this.dampenDistance(proposedTransform - lowerTransform) 501 : proposedTransform; 502 503 this.initialTransform = handoffTransform; 504 this.startY = clientY; 505 this.currentY = clientY; 506 this.lastY = clientY; 507 this.translateY(handoffTransform); 508 this.updateScrollableHeight(this.getVisibleDrawerHeight(handoffTransform)); 509 return true; 510 } 511 } 512 513 return false; 514 } 515 516 isInteractiveElement(target) { 517 if (!(target instanceof Element)) return false; 518 return Boolean(target.closest("input, textarea, select, button, a, [contenteditable='true']")); 519 } 520 521 shouldDrag(target, isDraggingDown) { 522 if (this.hasHandleTarget && target instanceof Node && this.handleTarget.contains(target)) { 523 return true; 524 } 525 526 if (!(target instanceof Element)) return true; 527 if (target.closest("[data-drawer-no-drag]")) return false; 528 if (this.isInteractiveElement(target)) return false; 529 530 const highlightedText = window.getSelection()?.toString(); 531 if (highlightedText && highlightedText.length > 0) return false; 532 533 const now = Date.now(); 534 if (isDraggingDown && now - this.lastTimeDragPrevented < this.scrollLockTimeoutValue) { 535 return false; 536 } 537 538 let element = target; 539 while (element && element !== this.dialogTarget) { 540 if (element instanceof HTMLElement && element.scrollHeight > element.clientHeight + 1) { 541 if (element.scrollTop > 0) { 542 this.lastTimeDragPrevented = now; 543 return false; 544 } 545 } 546 547 element = element.parentElement; 548 } 549 550 // Right after open, avoid accidental close drags while users start scrolling. 551 if (isDraggingDown && now - this.openTime < this.scrollLockTimeoutValue) { 552 return false; 553 } 554 555 return true; 556 } 557 558 computeOverlayOpacity(index) { 559 const maxOpacity = DRAWER_MAX_OVERLAY_OPACITY; 560 if (this.fadeFromIndexValue < 0) return maxOpacity; 561 562 const maxSnapIndex = this.getMaxSnapIndex(); 563 if (maxSnapIndex <= 0) return maxOpacity; 564 if (index < this.fadeFromIndexValue) return 0; 565 566 const denominator = maxSnapIndex - this.fadeFromIndexValue; 567 if (denominator <= 0) return maxOpacity; 568 569 const progress = this.clamp((index - this.fadeFromIndexValue) / denominator, 0, 1); 570 return maxOpacity * progress; 571 } 572 573 setOverlayOpacity(opacity) { 574 const clampedOpacity = this.clamp(opacity, 0, DRAWER_MAX_OVERLAY_OPACITY); 575 this.dialogTarget.style.setProperty("--drawer-overlay-opacity", `${clampedOpacity}`); 576 } 577 578 resetClosedVisualState() { 579 this.dialogTarget.style.transition = "none"; 580 this.translateY("100%"); 581 this.setOverlayOpacity(0); 582 } 583 584 async open(event) { 585 if (this.isOpen || this.isOpening || this.isLoadingContent) return; 586 587 this.isOpening = true; 588 let lockAcquired = false;
589 590 const triggerElement = 591 event?.currentTarget instanceof HTMLElement && 592 event.currentTarget.matches('button, [role="button"], input[type="button"], input[type="submit"], a[href]') 593 ? event.currentTarget 594 : null; 595 const shouldShowLoadingState = this.lazyLoadValue && !this.contentLoaded; 596 597 try { 598 // Acquire scrollbar compensation immediately so page scroll is locked 599 // before any async lazy-loading delay. 600 window.__openDialogCount++; 601 this.isOpen = true; 602 lockAcquired = true; 603 this.openTime = Date.now(); 604 605 if (window.__openDialogCount === 1) { 606 const scrollbarWidth = this.getScrollbarWidth(); 607 if (scrollbarWidth > 0) { 608 document.documentElement.style.setProperty("--scrollbar-compensation", `${scrollbarWidth}px`); 609 } else { 610 document.documentElement.style.removeProperty("--scrollbar-compensation"); 611 } 612 } 613 document.body.classList.add("drawer-open"); 614 615 // If lazy loading is enabled and content hasn't been loaded yet, load it now 616 if (shouldShowLoadingState) { 617 this.isLoadingContent = true; 618 this.setTriggerLoadingState(triggerElement, true); 619 try { 620 await this.#loadTemplateContent(); 621 this.contentLoaded = true; 622 } finally { 623 this.isLoadingContent = false; 624 this.setTriggerLoadingState(triggerElement, false); 625 } 626 } 627 628 // Always start from fully closed so lazy drawers still run full enter motion. 629 this.dialogTarget.removeAttribute("closing"); 630 this.resetClosedVisualState(); 631 this.dialogTarget.showModal(); 632 633 // Set up focus trapping 634 this.setupFocusTrapping(); 635 this.normalizeInitialFocus(); 636 637 // Always start from first snap point 638 this.activeSnapPointValue = 0; 639 this.primeContentHeightForSnapPoint(this.activeSnapPointValue); 640 641 const openDuration = this.getDuration(this.drawerOpenDurationMs); 642 // Animate in with spring-like easing 643 this.openFrame = requestAnimationFrame(() => { 644 this.openFrame = null; 645 if (!this.isOpen || this.isClosing) return; 646 647 this.snapToPoint(this.activeSnapPointValue, { 648 duration: this.drawerOpenDurationMs, 649 syncHeightDuringTransition: false, 650 }); 651 652 // Reset opening flag after animation duration 653 this.openCompletionTimeout = setTimeout(() => { 654 this.openCompletionTimeout = null; 655 if (!this.isOpen || this.isClosing) return; 656 657 this.isOpening = false; 658 659 // Force update height in case viewport changed during animation (e.g., mobile address bar) 660 if (this.snapPointsValue[this.activeSnapPointValue] === "auto") { 661 this.updateAutoHeight(); 662 } 663 }, openDuration); 664 }); 665 666 // Add drag listeners 667 if (this.dismissibleValue) { 668 this.addEventListeners(); 669 } 670 } catch (error) { 671 // Roll back provisional lock if opening fails (e.g. lazy-load error). 672 if (lockAcquired && this.isOpen) { 673 window.__openDialogCount = Math.max(0, window.__openDialogCount - 1); 674 this.isOpen = false; 675 676 if (window.__openDialogCount === 0) { 677 document.documentElement.style.removeProperty("--scrollbar-compensation"); 678 document.body.classList.remove("modal-open", "slideover-open", "drawer-open"); 679 } else { 680 this.syncBodyOverlayClasses(); 681 } 682 } 683 684 this.isOpening = false; 685 throw error; 686 } 687 } 688 689 close() { 690 if (!this.isOpen || this.isClosing) return; 691 692 this.cancelDeferredMotionWork(); 693 this.isOpening = false; // Cancel any in-flight opening state when closing early. 694 this.isClosing = true; // Set closing flag 695 this.stopSnapHeightSync(); 696 this.releaseActivePointerCapture(); 697 this.clearDraggingCursor(); 698 this.isDragging = false; 699 this.isAllowedToDrag = false; 700 this.dragSnapPointTransforms = null; 701 this.activePointerId = null; 702 this.dialogTarget.setAttribute("closing", ""); 703 this.dialogTarget.style.transition = this.getTransformTransition(this.drawerCloseDurationMs); 704 this.translateY("100%"); 705 this.setOverlayOpacity(0); 706 707 const closeDuration = this.getDuration(this.drawerCloseDurationMs); 708 this.queueFinishClosing(closeDuration); 709 } 710 711 finishClosing() { 712 if (this.closeTimeout) { 713 clearTimeout(this.closeTimeout); 714 this.closeTimeout = null; 715 } 716 this.cancelDeferredMotionWork(); 717 this.stopSnapHeightSync(); 718 this.releaseActivePointerCapture();
719 this.clearDraggingCursor(); 720 this.clearPendingFocusTimeout(); 721 722 const wasOpen = this.isOpen; 723 this.isOpen = false; 724 725 this.dialogTarget.removeAttribute("closing"); 726 this.dialogTarget.close(); 727 this.resetClosedVisualState(); 728 729 if (wasOpen) { 730 window.__openDialogCount = Math.max(0, window.__openDialogCount - 1); 731 } 732 this.isOpening = false; 733 this.isClosing = false; // Reset closing flag 734 735 if (window.__openDialogCount === 0) { 736 document.documentElement.style.removeProperty("--scrollbar-compensation"); 737 document.body.classList.remove("modal-open", "slideover-open", "drawer-open"); 738 } else { 739 this.syncBodyOverlayClasses(); 740 } 741 742 this.removeEventListeners(); 743 744 // Reset to initial snap point for next opening 745 this.activeSnapPointValue = 0; 746 747 // Reset drag-related state 748 this.isDragging = false; 749 this.velocity = 0; 750 this.startY = 0; 751 this.currentY = 0; 752 this.lastY = 0; 753 this.initialTransform = 0; 754 this.activePointerId = null; 755 this.isAllowedToDrag = false; 756 757 // For template-based lazy drawers, restore loading state before the next open. 758 if (this.lazyLoadValue && this.hasTemplateTarget) { 759 this.contentLoaded = false; 760 const container = this.dialogTarget.querySelector("[data-drawer-content]"); 761 if (container) { 762 container.innerHTML = ` 763 <div class="px-6 pb-6 pt-4 text-center"> 764 <div class="inline-block size-6 animate-spin rounded-full border-[3px] border-current border-t-transparent text-neutral-600 dar
764k:text-neutral-400" role="status"> 765 <span class="sr-only">Loading...</span> 766 </div> 767 <p class="mt-2 text-sm text-neutral-500 dark:text-neutral-400">Loading content...</p> 768 </div> 769 `; 770 } 771 } 772 } 773 774 applySnapPointState(index, snapPoint = this.snapPointsValue[index]) { 775 if (!snapPoint) return; 776 777 this.activeSnapPointValue = index; 778 779 // New logic for revealing content based on snap points 780 if (this.hasSnapContentTarget) { 781 this.snapContentTargets.forEach((content) => { 782 // Default to 0 if the attribute is missing 783 const showFromIndex = parseInt(content.dataset.showFromSnapPoint, 10); 784 const effectiveShowFromIndex = isNaN(showFromIndex) ? 0 : showFromIndex; 785 786 if (index >= effectiveShowFromIndex) { 787 content.classList.remove("hidden"); 788 } else { 789 content.classList.add("hidden"); 790 } 791 }); 792 } 793 794 // Update snap indicator if present 795 if (this.hasSnapIndicatorTarget) { 796 this.snapIndicatorTarget.textContent = 797 snapPoint === "auto" ? `${Math.min(this.dialogTarget.scrollHeight, window.innerHeight * 0.9)}px` : snapPoint; 798 } 799 800 // Handle overlay fade effect 801 this.setOverlayOpacity(this.computeOverlayOpacity(index)); 802 } 803 804 snapToPoint(index, options = {}) { 805 const { 806 animate = true, 807 duration = DRAWER_SNAP_DURATION_MS, 808 primeHeightBeforeSync = true, 809 syncHeightDuringTransition = true, 810 } = options; 811 const snapPoint = this.snapPointsValue[index]; 812 if (!snapPoint) return; 813 this.stopSnapHeightSync(); 814 815 this.applySnapPointState(index, snapPoint); 816 817 // Store whether we should animate 818 const shouldAnimate = animate && !this.isDragging; 819 const transitionDuration = shouldAnimate ? this.getDuration(duration) : 0; 820 const hasTransformTransition = transitionDuration > 0; 821 822 // Calculate transform based on snap point 823 let translateY = 0; 824 if (snapPoint === "auto") { 825 // For "auto", calculate the actual content height 826 translateY = this.calculateAutoTranslateY(); 827 } else if (snapPoint.endsWith("px")) { 828 translateY = `calc(100% - ${snapPoint})`; 829 } else if (snapPoint.endsWith("%")) { 830 // Use window.innerHeight for percentage-based snap points to account for mobile browser UI 831 const percentage = parseInt(snapPoint) / 100; 832 const pixelHeight = window.innerHeight * percentage; 833 translateY = `calc(100% - ${pixelHeight}px)`; 834 } 835 836 // Apply transform with or without animation 837 if (hasTransformTransition) { 838 this.dialogTarget.style.transition = this.getTransformTransition(duration); 839 } else { 840 this.dialogTarget.style.transition = "none"; 841 } 842 this.translateY(translateY); 843 844 if (hasTransformTransition) { 845 if (!syncHeightDuringTransition) return; 846 847 // Prime layout before snap transitions to avoid content jumps on drag release. 848 if (primeHeightBeforeSync) { 849 this.updateScrollableHeight(); 850 } 851 852 // Keep layout updates in lockstep with transform animation. 853 this.syncScrollableHeightDuringSnap(transitionDuration); 854 } else { 855 this.updateScrollableHeight(); 856 } 857 } 858 859 // Touch and mouse event handlers 860 addEventListeners() { 861 this.dialogTarget.addEventListener("pointerdown", this.boundHandleStart); 862 this.dialogTarget.addEventListener("pointermove", this.boundHandleMove); 863 this.dialogTarget.addEventListener("pointerup", this.boundHandleEnd); 864 this.dialogTarget.addEventListener("pointercancel", this.boundHandleCancel); 865 } 866 867 removeEventListeners() { 868 this.dialogTarget.removeEventListener("pointerdown", this.boundHandleStart); 869 this.dialogTarget.removeEventListener("pointermove", this.boundHandleMove); 870 this.dialogTarget.removeEventListener("pointerup", this.boundHandleEnd); 871 this.dialogTarget.removeEventListener("pointercancel", this.boundHandleCancel); 872 } 873 874 handleStart(event) { 875 if (event.button !== undefined && event.button !== 0) return; 876 // Ignore secondary touches while one drag interaction is active. 877 if (this.isDragging) return; 878 879 // Only start dragging from handle or if no handle exists 880 const target = event.target; 881 if (target === this.dialogTarget) return; 882 const isHandle = this.hasHandleTarget && target instanceof Node && this.handleTarget.contains(target); 883 const hasNoHandle = !this.hasHandleTarget; 884 885 if (!isHandle && !hasNoHandle) return; 886 if (!isHandle && !this.shouldDrag(target, false)) return; 887 888 this.stopSnapHeightSync(); 889 this.isDragging = true; 890 this.isAllowedToDrag = Boolean(isHandle); 891 this.activePointerId = event.pointerId; 892 this.dialogTarget.style.transition = "none"; 893 894 const clientY = event.clientY; 895 this.startY = clientY; 896 this.currentY = clientY; 897 this.lastY = clientY; 898 this.lastTime = Date.now(); 899 900 // Reset velocity at the start of each drag 901 this.velocity = 0; 902 903 // Store the initial transform 904 this.initialTransform = this.getCurrentTransformY(); 905 this.captureDragSnapPointTransforms(); 906 this.captureActivePointer(event.pointerId); 907 this.applyDraggingCursor(event.pointerType); 908 909 if (event.pointerType === "touch") { 910 event.preventDefault(); 911 } 912 } 913 914 handleMove(event) { 915 if (!this.isDragging) return; 916 if (this.activePointerId !== null && event.pointerId !== this.activePointerId) return; 917 918 // If mouse/pen button is no longer pressed, force-end drag. 919 // This handles cases where pointerup happens outside the browser window/chrome. 920 if ((event.pointerType === "mouse" || event.pointerType === "pen") && (event.buttons & 1) === 0) { 921 this.cancelActiveDrag(); 922 return; 923 } 924 925 const clientY = event.clientY; 926 const deltaY = clientY - this.startY; 927 const isDraggingDown = deltaY > 0; 928 929 if (!this.isAllowedToDrag && !this.shouldDrag(event.target, isDraggingDown)) return; 930 this.isAllowedToDrag = true; 931 932 this.currentY = clientY; 933 934 // Calculate velocity 935 const currentTime = Date.now(); 936 const timeDelta = currentTime - this.lastTime; 937 this.velocity = timeDelta > 0 ? (clientY - this.lastY) / timeDelta : 0; 938 939 this.lastY = clientY; 940 this.lastTime = currentTime; 941 942 const proposedTransform = this.initialTransform + deltaY; 943 if (this.trySnapToAdjacentPointDuringDrag(clientY, proposedTransform, deltaY)) { 944 if (event.pointerType === "touch") { 945 event.preventDefault(); 946 } 947 return; 948 } 949 950 // Provide live movement across snap points. 951 // Apply rubber-banding only at global extremes to avoid a jump when crossing each snap boundary. 952 const maxSnapIndex = this.getMaxSnapIndex();
953 const minTransform = this.getSnapPointTransformY(maxSnapIndex) ?? this.initialTransform; 954 const maxTransform = this.getSnapPointTransformY(0) ?? this.initialTransform; 955 const isOverdraggingUp = proposedTransform < minTransform; 956 const isOverdraggingDown = 957 proposedTransform > maxTransform && !(this.activeSnapPointValue === 0 && this.dismissibleValue); 958 959 let newTransform = proposedTransform; 960 if (isOverdraggingUp) { 961 const overdrag = minTransform - proposedTransform; 962 newTransform = minTransform - this.dampenDistance(overdrag); 963 } else if (proposedTransform > maxTransform) { 964 if (this.activeSnapPointValue === 0 && this.dismissibleValue) { 965 // Keep close drag direct at the first snap point for predictable dismiss gestures. 966 newTransform = proposedTransform; 967 } else { 968 const overdrag = proposedTransform - maxTransform; 969 newTransform = maxTransform + this.dampenDistance(overdrag); 970 } 971 } 972 // Batch drag-related layout reads/writes to animation frames for smoother motion. 973 this.scheduleDragVisualUpdate(newTransform); 974 975 if (event.pointerType === "touch") { 976 event.preventDefault(); 977 } 978 } 979 980 handleEnd(event) { 981 if (!this.isDragging) return; 982 if (this.activePointerId !== null && event.pointerId !== this.activePointerId) return; 983 984 if (this.dragFrame !== null) { 985 cancelAnimationFrame(this.dragFrame); 986 this.dragFrame = null; 987 } 988 this.flushPendingDragVisualUpdate(); 989 990 this.releaseActivePointerCapture(); 991 this.clearDraggingCursor(); 992 993 this.isDragging = false; 994 this.isAllowedToDrag = false; 995 this.activePointerId = null; 996 this.dragSnapPointTransforms = null; 997 998 const deltaY = this.currentY - this.startY; 999 const drawerHeight = this.dialogTarget.offsetHeight; 1000 const threshold = drawerHeight * this.closeThresholdValue; 1001 const absVelocity = Math.abs(this.velocity); 1002 const maxSnapIndex = this.getMaxSnapIndex(); 1003 const canMoveUp = this.activeSnapPointValue < maxSnapIndex; 1004 const canMoveDown = this.activeSnapPointValue > 0; 1005 1006 // Upward swipe: move to a larger snap point and optionally skip points based on velocity. 1007 if (deltaY < -30 || this.velocity < -this.velocityThresholdValue) { 1008 if (canMoveUp) { 1009 const maxStep = maxSnapIndex - this.activeSnapPointValue; 1010 const step = Math.min(this.getSnapStepFromVelocity(absVelocity), maxStep); 1011 this.snapToPoint(this.activeSnapPointValue + step); 1012 } else { 1013 this.snapToPoint(this.activeSnapPointValue); 1014 } 1015 return; 1016 } 1017 1018 // Downward swipe: move to a smaller snap point, skip points with velocity, or close. 1019 if (deltaY > 30 || this.velocity > this.velocityThresholdValue) { 1020 if (canMoveDown) { 1021 const maxStep = this.activeSnapPointValue; 1022 const step = Math.min(this.getSnapStepFromVelocity(absVelocity), maxStep); 1023 const nextIndex = this.activeSnapPointValue - step; 1024 const shouldCloseFromFirstSnap = 1025 nextIndex === 0 && (deltaY > threshold || this.velocity > this.velocityThresholdValue * 1.35); 1026 1027 if (shouldCloseFromFirstSnap) { 1028 if (this.dismissibleValue) { 1029 this.close(); 1030 } else { 1031 this.bounce(); 1032 this.snapToPoint(this.activeSnapPointValue); 1033 } 1034 } else { 1035 this.snapToPoint(nextIndex); 1036 } 1037 } else if (deltaY > threshold || this.velocity > this.velocityThresholdValue) { 1038 if (this.dismissibleValue) { 1039 this.close(); 1040 } else { 1041 this.bounce(); 1042 this.snapToPoint(this.activeSnapPointValue); 1043 } 1044 } else { 1045 this.snapToPoint(this.activeSnapPointValue); 1046 } 1047 return; 1048 } 1049 1050 // No decisive swipe: snap back to current point. 1051 this.snapToPoint(this.activeSnapPointValue); 1052 } 1053 1054 handleCancel(event) { 1055 if (!this.isDragging) return; 1056 if (this.activePointerId !== null && event.pointerId !== this.activePointerId) return; 1057 this.cancelActiveDrag(); 1058 } 1059 1060 cancelActiveDrag() { 1061 if (!this.isDragging) return; 1062 1063 if (this.dragFrame !== null) { 1064 cancelAnimationFrame(this.dragFrame); 1065 this.dragFrame = null; 1066 } 1067 this.flushPendingDragVisualUpdate(); 1068 1069 this.releaseActivePointerCapture();
1070 this.clearDraggingCursor(); 1071 1072 this.isDragging = false; 1073 this.isAllowedToDrag = false; 1074 this.activePointerId = null; 1075 this.dragSnapPointTransforms = null; 1076 this.snapToPoint(this.activeSnapPointValue); 1077 } 1078 1079 handleWindowBlur() { 1080 this.cancelActiveDrag(); 1081 } 1082 1083 getCurrentTransformY() { 1084 const transform = window.getComputedStyle(this.dialogTarget).transform; 1085 if (transform === "none") { 1086 // If no transform matrix, calculate from the current position 1087 const rect = this.dialogTarget.getBoundingClientRect(); 1088 const windowHeight = window.innerHeight; 1089 return rect.top - (windowHeight - this.dialogTarget.offsetHeight); 1090 } 1091 1092 const matrix3d = transform.match(/matrix3d\((.+)\)/); 1093 if (matrix3d) { 1094 const values = matrix3d[1].split(/,\s*/); 1095 return parseFloat(values[13]); 1096 } 1097 1098 const matrix2d = transform.match(/matrix\((.+)\)/); 1099 if (matrix2d) { 1100 const values = matrix2d[1].split(/,\s*/); 1101 return parseFloat(values[5]); 1102 } 1103 return 0; 1104 } 1105 1106 applyDraggingCursor(pointerType) { 1107 if (pointerType === "touch") return; 1108 this.dialogTarget.style.cursor = "grabbing"; 1109 if (this.hasHandleTarget) { 1110 this.handleTarget.style.cursor = "grabbing"; 1111 } 1112 this.previousBodyCursor = document.body.style.cursor || ""; 1113 document.body.style.cursor = "grabbing"; 1114 } 1115 1116 clearDraggingCursor() { 1117 this.dialogTarget.style.removeProperty("cursor"); 1118 if (this.hasHandleTarget) { 1119 this.handleTarget.style.removeProperty("cursor"); 1120 } 1121 1122 if (this.previousBodyCursor === null) return; 1123 if (this.previousBodyCursor.length > 0) { 1124 document.body.style.cursor = this.previousBodyCursor; 1125 } else { 1126 document.body.style.removeProperty("cursor"); 1127 } 1128 this.previousBodyCursor = null; 1129 } 1130 1131 captureActivePointer(pointerId) { 1132 const captureElement = this.hasHandleTarget ? this.handleTarget : this.dialogTarget; 1133 if (!captureElement?.setPointerCapture) return; 1134 1135 try { 1136 captureElement.setPointerCapture(pointerId); 1137 this.pointerCaptureElement = captureElement; 1138 } catch (_error) { 1139 this.pointerCaptureElement = null; 1140 } 1141 } 1142 1143 releaseActivePointerCapture() { 1144 if (this.activePointerId === null) { 1145 this.pointerCaptureElement = null; 1146 return; 1147 } 1148 1149 if (this.pointerCaptureElement?.releasePointerCapture) { 1150 try { 1151 this.pointerCaptureElement.releasePointerCapture(this.activePointerId); 1152 } catch (_error) { 1153 // Ignore release errors on unsupported/browser edge cases. 1154 } 1155 } 1156 1157 this.pointerCaptureElement = null; 1158 } 1159 1160 backdropClose(event) { 1161 // Only close if clicking on the actual backdrop area (outside the dialog's content box) 1162 if (event.target.nodeName === "DIALOG") { 1163 // Get the dialog's bounding rect to check if click is outside content area 1164 const rect = this.dialogTarget.getBoundingClientRect(); 1165 // Click is on backdrop if it's outside the dialog's content box 1166 // This prevents scrollbar clicks from being treated as backdrop clicks 1167 const isBackdropClick = 1168 event.clientX < rect.left || 1169 event.clientX > rect.right || 1170 event.clientY < rect.top || 1171 event.clientY > rect.bottom; 1172 1173 if (isBackdropClick) { 1174 event.stopPropagation(); 1175 // Only close if dismissible 1176 if (this.dismissibleValue) { 1177 this.close(); 1178 } else { 1179 // Show bounce animation when trying to dismiss 1180 this.bounce(); 1181 } 1182 } 1183 } 1184 } 1185 1186 show(event) { 1187 this.open(event); 1188 } 1189 1190 hide(event) { 1191 if (event) event.preventDefault(); 1192 this.close(); 1193 } 1194 1195 beforeCache() { 1196 // Close immediately without animation during navigation 1197 if (this.isOpen) { 1198 this.finishClosing(); 1199 } 1200 } 1201 1202 getScrollbarWidth() { 1203 const outer = document.createElement("div"); 1204 outer.style.visibility = "hidden"; 1205 outer.style.overflow = "scroll"; 1206 outer.style.msOverflowStyle = "scrollbar"; 1207 document.body.appendChild(outer); 1208 1209 const inner = document.createElement("div"); 1210 outer.appendChild(inner); 1211 1212 const scrollbarWidth = outer.offsetWidth - inner.offsetWidth; 1213 outer.parentNode.removeChild(outer); 1214 1215 return scrollbarWidth; 1216 } 1217 1218 setTriggerLoadingState(triggerElement, isLoading) { 1219 if (!(triggerElement instanceof HTMLElement)) return; 1220 1221 const supportsDisabled = "disabled" in triggerElement; 1222 1223 if (isLoading) { 1224 triggerElement.classList.add("!cursor-wait"); 1225 triggerElement.setAttribute("aria-busy", "true"); 1226 1227 if (supportsDisabled) { 1228 triggerElement.dataset.overlayOriginalDisabled = triggerElement.disabled ? "true" : "false"; 1229 triggerElement.disabled = true; 1230 } else { 1231 triggerElement.dataset.overlayOriginalAriaDisabled = triggerElement.getAttribute("aria-disabled") || "";
1232 triggerElement.dataset.overlayHadPointerEventsNone = triggerElement.classList.contains("pointer-events-none") 1233 ? "true" 1234 : "false"; 1235 triggerElement.setAttribute("aria-disabled", "true"); 1236 triggerElement.classList.add("pointer-events-none"); 1237 } 1238 1239 return; 1240 } 1241 1242 triggerElement.classList.remove("!cursor-wait"); 1243 triggerElement.removeAttribute("aria-busy"); 1244 1245 if (supportsDisabled) { 1246 triggerElement.disabled = triggerElement.dataset.overlayOriginalDisabled === "true"; 1247 delete triggerElement.dataset.overlayOriginalDisabled; 1248 } else { 1249 const originalAriaDisabled = triggerElement.dataset.overlayOriginalAriaDisabled; 1250 if (originalAriaDisabled) { 1251 triggerElement.setAttribute("aria-disabled", originalAriaDisabled); 1252 } else { 1253 triggerElement.removeAttribute("aria-disabled"); 1254 } 1255 1256 if (triggerElement.dataset.overlayHadPointerEventsNone !== "true") { 1257 triggerElement.classList.remove("pointer-events-none"); 1258 } 1259 1260 delete triggerElement.dataset.overlayOriginalAriaDisabled; 1261 delete triggerElement.dataset.overlayHadPointerEventsNone; 1262 } 1263 } 1264 1265 handleDialogClose() { 1266 this.cancelDeferredMotionWork(); 1267 1268 if (this.isOpen) { 1269 window.__openDialogCount = Math.max(0, window.__openDialogCount - 1); 1270 this.isOpen = false; 1271 1272 if (window.__openDialogCount === 0) { 1273 document.documentElement.style.removeProperty("--scrollbar-compensation"); 1274 document.body.classList.remove("modal-open", "slideover-open", "drawer-open"); 1275 } else { 1276 this.syncBodyOverlayClasses(); 1277 } 1278 } 1279 1280 this.isOpening = false; 1281 this.isClosing = false; 1282 this.dialogTarget.removeAttribute("closing"); 1283 this.resetClosedVisualState(); 1284 } 1285 1286 syncBodyOverlayClasses() { 1287 const hasOpenModalDialog = document.querySelector('dialog[open][data-modal-target="dialog"]'); 1288 const hasOpenModalDiv = document.querySelector('[data-modal-target="dialog"].modal-open'); 1289 const hasOpenSlideover = document.querySelector('dialog[open][data-slideover-target="dialog"]'); 1290 const hasOpenDrawer = document.querySelector('dialog[open][data-drawer-target="dialog"]'); 1291 1292 document.body.classList.toggle("modal-open", Boolean(hasOpenModalDialog || hasOpenModalDiv)); 1293 document.body.classList.toggle("slideover-open", Boolean(hasOpenSlideover)); 1294 document.body.classList.toggle("drawer-open", Boolean(hasOpenDrawer)); 1295 } 1296 1297 // Handle keydown events 1298 handleKeydown(event) { 1299 if (event.key === "Escape" && !this.dismissibleValue) { 1300 event.preventDefault(); 1301 event.stopPropagation(); 1302 event.stopImmediatePropagation(); 1303 this.bounce(); 1304 return false; 1305 } 1306 1307 // Handle Tab key for focus trapping 1308 if (event.key === "Tab") { 1309 this.handleTabKey(event); 1310 } 1311 } 1312 1313 // Handle escape key press (cancel event) 1314 handleEscape(event) { 1315 if (!this.dismissibleValue) { 1316 event.preventDefault(); 1317 event.stopPropagation(); 1318 event.stopImmediatePropagation(); 1319 this.bounce(); 1320 return false; 1321 } 1322 1323 // Only close if dismissible 1324 event.preventDefault(); 1325 this.close(); 1326 } 1327 1328 // Add bounce animation to indicate drawer won't close 1329 bounce() { 1330 // Prevent multiple bounces in quick succession 1331 if (this.isBouncing) return; 1332 1333 this.isBouncing = true; 1334 1335 // Save the current transform 1336 const currentTransform = this.dialogTarget.style.transform; 1337 1338 // Calculate bounce distance (smaller bounce for drawers) 1339 const currentY = this.getCurrentTransformY(); 1340 const bounceDistance = -20; // pixels 1341 1342 // Apply quick bounce animation 1343 this.dialogTarget.style.transition = this.getTransformTransition(DRAWER_BOUNCE_IN_DURATION_MS); 1344 this.translateY(currentY + bounceDistance); 1345 1346 setTimeout(() => { 1347 // Bounce back 1348 this.dialogTarget.style.transition = this.getTransformTransition(DRAWER_BOUNCE_OUT_DURATION_MS); 1349 this.dialogTarget.style.transform = currentTransform; 1350 1351 setTimeout(() => { 1352 // Reset bouncing flag after animation completes 1353 this.isBouncing = false;
1354 }, this.getDuration(DRAWER_BOUNCE_OUT_DURATION_MS)); 1355 }, this.getDuration(DRAWER_BOUNCE_IN_DURATION_MS)); 1356 } 1357 1358 // Handle window resize 1359 handleResize() { 1360 if (!this.isOpen || this.isOpening) return; // Ignore resizes during opening animation 1361 1362 // For any snap point, recalculate on resize 1363 // Use debouncing to avoid too many recalculations 1364 clearTimeout(this.resizeTimeout); 1365 this.resizeTimeout = setTimeout(() => { 1366 this.resizeTimeout = null; 1367 if (!this.isOpen || this.isClosing) return; 1368 1369 // Store current transition 1370 const currentTransition = this.dialogTarget.style.transition; 1371 1372 // Disable transition for immediate repositioning 1373 this.dialogTarget.style.transition = "none"; 1374 1375 // Recalculate position 1376 this.snapToPoint(this.activeSnapPointValue); 1377 1378 // Re-enable transition after a frame 1379 requestAnimationFrame(() => { 1380 if (!this.isOpen || this.isClosing) return; 1381 this.dialogTarget.style.transition = currentTransition || this.getTransformTransition(DRAWER_SNAP_DURATION_MS); 1382 }); 1383 }, 150); 1384 } 1385 1386 getVisibleDrawerHeight(transformY = null) { 1387 const resolvedTransformY = Number.isFinite(transformY) ? transformY : this.getCurrentTransformY(); 1388 if (Number.isFinite(resolvedTransformY)) { 1389 return this.clamp(window.innerHeight - resolvedTransformY, 0, window.innerHeight); 1390 } 1391 1392 const rect = this.dialogTarget.getBoundingClientRect(); 1393 const top = Math.max(rect.top, 0); 1394 const bottom = Math.min(rect.bottom, window.innerHeight); 1395 return Math.max(0, bottom - top); 1396 } 1397 1398 getSnapPointHeight(index = this.activeSnapPointValue) { 1399 const snapPoint = this.snapPointsValue[index]; 1400 if (!snapPoint) return null; 1401 1402 if (snapPoint === "auto") { 1403 return this.measureAutoContentHeight(); 1404 } 1405 1406 if (snapPoint.endsWith("px")) { 1407 const parsedHeight = parseFloat(snapPoint); 1408 return Number.isFinite(parsedHeight) ? parsedHeight : null; 1409 } 1410 1411 if (snapPoint.endsWith("%")) { 1412 const parsedPercent = parseFloat(snapPoint); 1413 if (!Number.isFinite(parsedPercent)) return null; 1414 return window.innerHeight * (parsedPercent / 100); 1415 } 1416 1417 return null; 1418 } 1419 1420 getContentWrapper() { 1421 if (this.contentWrapper?.isConnected) return this.contentWrapper; 1422 1423 this.contentWrapper = this.dialogTarget.querySelector(":scope > div"); 1424 this.lastContentWrapperHeight = null; 1425 return this.contentWrapper; 1426 } 1427 1428 setContentWrapperHeight(heightPx) { 1429 const contentWrapper = this.getContentWrapper(); 1430 if (!contentWrapper || !Number.isFinite(heightPx)) return; 1431 1432 const clampedHeight = this.clamp(heightPx, 0, window.innerHeight); 1433 const heightValue = `${clampedHeight.toFixed(2)}px`; 1434 if (this.lastContentWrapperHeight === heightValue) return; 1435 1436 this.lastContentWrapperHeight = heightValue; 1437 1438 contentWrapper.style.height = heightValue; 1439 if (contentWrapper.style.maxHeight !== heightValue) { 1440 contentWrapper.style.maxHeight = heightValue; 1441 } 1442 if (contentWrapper.style.minHeight !== "0px") { 1443 contentWrapper.style.minHeight = "0px"; 1444 } 1445 } 1446 1447 primeContentHeightForSnapPoint(index = this.activeSnapPointValue) { 1448 const snapHeight = this.getSnapPointHeight(index); 1449 if (!Number.isFinite(snapHeight)) return; 1450 this.setContentWrapperHeight(snapHeight); 1451 } 1452 1453 measureAutoContentHeight() { 1454 const contentWrapper = this.getContentWrapper(); 1455 if (!contentWrapper) return window.innerHeight * 0.8; 1456 1457 const originalMaxHeight = contentWrapper.style.maxHeight; 1458 const originalHeight = contentWrapper.style.height; 1459 const originalMinHeight = contentWrapper.style.minHeight; 1460 contentWrapper.style.maxHeight = "none"; 1461 contentWrapper.style.height = "auto"; 1462 contentWrapper.style.minHeight = "0px"; 1463 contentWrapper.offsetHeight; 1464 const naturalHeight = contentWrapper.scrollHeight; 1465 contentWrapper.style.maxHeight = originalMaxHeight; 1466 contentWrapper.style.height = originalHeight; 1467 contentWrapper.style.minHeight = originalMinHeight; 1468 1469 return Math.min(naturalHeight, window.innerHeight * 0.9); 1470 } 1471 1472 // Keep scrollable area tied to the drawer's actual visible height. 1473 updateScrollableHeight(visibleDrawerHeight = null) { 1474 const nextVisibleHeight = Number.isFinite(visibleDrawerHeight) 1475 ? visibleDrawerHeight 1476 : this.getVisibleDrawerHeight(); 1477 this.setContentWrapperHeight(nextVisibleHeight); 1478 } 1479 1480 // New method to calculate auto height translateY 1481 calculateAutoTranslateY() { 1482 const effectiveHeight = this.measureAutoContentHeight(); 1483 return `calc(100% - ${effectiveHeight}px)`; 1484 } 1485 1486 // New method to update height when content changes 1487 updateAutoHeight() { 1488 if (!this.isOpen || this.isDragging || this.isClosing) return; 1489 1490 const currentSnapPoint = this.snapPointsValue[this.activeSnapPointValue]; 1491 if (currentSnapPoint !== "auto") return; 1492 1493 // Wait for any animations to complete 1494 if (this.autoHeightFrame !== null) { 1495 cancelAnimationFrame(this.autoHeightFrame); 1496 } 1497 1498 this.autoHeightFrame = requestAnimationFrame(() => { 1499 this.autoHeightFrame = null; 1500 if (!this.isOpen || this.isDragging || this.isClosing) return; 1501 1502 // Calculate new height 1503 const newTranslateY = this.calculateAutoTranslateY(); 1504 1505 // Apply transform with smooth transition 1506 this.dialogTarget.style.transition = this.getTransformTransition(DRAWER_SNAP_DURATION_MS); 1507 this.translateY(newTranslateY); 1508 1509 // Update scrollable height 1510 this.updateScrollableHeight(); 1511 }); 1512 } 1513 1514 async #loadTemplateContent() { 1515 // Find the container in the dialog to append content to 1516 const container = this.dialogTarget.querySelector("[data-drawer-content]") || this.dialogTarget; 1517 1518 // Check if we should use Turbo Frame lazy loading 1519 if (this.turboFrameSrcValue) { 1520 // Look for a turbo-frame in the container 1521 let turboFrame = container.querySelector("turbo-frame"); 1522 1523 if (!turboFrame) { 1524 // Create a turbo-frame if it doesn't exist 1525 turboFrame = document.createElement("turbo-frame"); 1526 turboFrame.id = "drawer-lazy-content"; 1527 1528 // Clear any loading indicators or placeholder content 1529 container.innerHTML = ""; 1530 container.appendChild(turboFrame); 1531 } 1532 1533 // Set the src to trigger the lazy load 1534 turboFrame.src = this.turboFrameSrcValue; 1535 1536 // Wait for the turbo-frame to load 1537 return new Promise((resolve) => { 1538 const handleLoad = () => { 1539 turboFrame.removeEventListener("turbo:frame-load", handleLoad); 1540 resolve(); 1541 }; 1542 1543 turboFrame.addEventListener("turbo:frame-load", handleLoad); 1544
1545 // Fallback timeout in case the frame doesn't load 1546 setTimeout(() => { 1547 turboFrame.removeEventListener("turbo:frame-load", handleLoad); 1548 resolve(); 1549 }, 5000); 1550 }); 1551 } else if (this.hasTemplateTarget) { 1552 // Use template-based lazy loading 1553 const templateContent = this.templateTarget.content.cloneNode(true); 1554 1555 // Clear any loading indicators or placeholder content 1556 const container = this.dialogTarget.querySelector("[data-drawer-content]") || this.dialogTarget; 1557 container.innerHTML = ""; 1558 1559 // Append the template content 1560 container.appendChild(templateContent); 1561 } 1562 } 1563 1564 // Set up focus trapping for the drawer 1565 setupFocusTrapping() { 1566 // Get all focusable elements within the drawer 1567 this.updateFocusableElements(); 1568 1569 this.clearPendingFocusTimeout(); 1570 1571 // Delay focus slightly so the drawer can start animating. 1572 // Keep focus on the dialog itself to avoid initial control focus on open. 1573 this.focusTimeout = setTimeout(() => { 1574 this.focusTimeout = null; 1575 if (!this.isOpen || this.isClosing) return; 1576 this.focusDialogContainer(); 1577 }, 100); 1578 } 1579 1580 clearPendingFocusTimeout() { 1581 if (this.focusTimeout) { 1582 clearTimeout(this.focusTimeout); 1583 this.focusTimeout = null; 1584 } 1585 } 1586 1587 focusElementWithoutScroll(element) { 1588 if (!(element instanceof HTMLElement)) return; 1589 1590 try { 1591 element.focus({ preventScroll: true }); 1592 } catch (_error) { 1593 element.focus(); 1594 } 1595 } 1596 1597 focusDialogContainer() { 1598 if (!this.dialogTarget.hasAttribute("tabindex")) { 1599 this.dialogTarget.setAttribute("tabindex", "-1"); 1600 } 1601 this.focusElementWithoutScroll(this.dialogTarget); 1602 } 1603 1604 // Some browsers move focus to the first focusable control when showModal() runs. 1605 // Normalize by keeping initial focus on the dialog container. 1606 normalizeInitialFocus() { 1607 const focusedElement = this.dialogTarget.querySelector(":focus"); 1608 if (!(focusedElement instanceof HTMLElement)) return; 1609 if (focusedElement === this.dialogTarget) return; 1610 1611 focusedElement.blur(); 1612 this.focusDialogContainer(); 1613 } 1614 1615 // Update the list of focusable elements 1616 updateFocusableElements() { 1617 const focusableSelector = [ 1618 "a[href]", 1619 "area[href]", 1620 'input:not([disabled]):not([tabindex="-1"])', 1621 'button:not([disabled]):not([tabindex="-1"])', 1622 'textarea:not([disabled]):not([tabindex="-1"])', 1623 'select:not([disabled]):not([tabindex="-1"])', 1624 "details", 1625 '[tabindex]:not([tabindex="-1"])', 1626 '[contenteditable]:not([contenteditable="false"])', 1627 ].join(","); 1628 1629 this.focusableElements = Array.from(this.dialogTarget.querySelectorAll(focusableSelector)).filter((element) => { 1630 // Filter out elements that are not visible or have display: none 1631 return ( 1632 element.offsetWidth > 0 && 1633 element.offsetHeight > 0 && 1634 getComputedStyle(element).display !== "none" && 1635 getComputedStyle(element).visibility !== "hidden" 1636 ); 1637 }); 1638 1639 this.firstFocusableElement = this.focusableElements[0] || null; 1640 this.lastFocusableElement = this.focusableElements[this.focusableElements.length - 1] || null; 1641 } 1642 1643 // Handle Tab key for focus trapping 1644 handleTabKey(event) { 1645 // Update focusable elements in case the DOM has changed 1646 this.updateFocusableElements(); 1647 1648 if (this.focusableElements.length === 0) { 1649 // If no focusable elements, prevent tab and keep focus on dialog 1650 event.preventDefault(); 1651 return; 1652 } 1653 1654 if (this.focusableElements.length === 1) { 1655 // If only one focusable element, prevent tab and keep focus on it 1656 event.preventDefault(); 1657 this.firstFocusableElement.focus(); 1658 return; 1659 } 1660 1661 // Handle normal tab navigation with wrapping 1662 if (event.shiftKey) { 1663 // Shift+Tab: moving backwards 1664 if (document.activeElement === this.firstFocusableElement) { 1665 event.preventDefault(); 1666 this.lastFocusableElement.focus(); 1667 } 1668 } else { 1669 // Tab: moving forwards 1670 if (document.activeElement === this.lastFocusableElement) { 1671 event.preventDefault(); 1672 this.firstFocusableElement.focus(); 1673 } 1674 } 1675 } 1676};
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.