PageSourceSearch

https://railsblocks.com/assets/controllers/drawer_controller-b8666…27cf2c4dff429d76f7d59d9d3900813c2a95f677.js

js railsblocks.com collected 2026-09-25 20:23:13 UTC 58,507 bytes, 1,676 lines download raw bytes

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.