import { NAME, EVENT, CLASS_NAME, SELECTOR, Default, DefaultType } from './constants'; import type { BootstrapSheetOptions, ResolvedSheetOptions } from './constants'; import { resolveElement, extractTargetSelector, clamp, extractDataAttributes, validateConfigTypes, getTranslateY, springParameters, } from './utils'; import Backdrop from './backdrop'; import ScrollBarHelper from './scrollbar'; import FocusTrap, { InertManager } from './focus-trap'; import SpringAnimator from './spring-animator'; import DragController from './drag-controller'; import DetentModel, { parseDetents } from './detents'; export type { BootstrapSheetOptions } from './constants'; const INSTANCES = new WeakMap(); /** * Bound event handlers kept for removal */ interface SheetHandlers { escape: ((event: KeyboardEvent) => void) | null; dismiss: (() => void) | null; } /** * @class BootstrapSheet - A touch-friendly bottom sheet component for Bootstrap 5 * @version 0.5.1 * @author Sergey Mironov * @license MIT (https://github.com/mironovsergey/bootstrap-sheet/blob/main/LICENSE) */ class BootstrapSheet { // ==================== Shared state ==================== /** Open sheets in the order they were shown; the last one is on top */ static #openSheets: BootstrapSheet[] = []; // ==================== Core elements ==================== /** Sheet element */ #element: HTMLElement; /** Resolved configuration object */ #config: ResolvedSheetOptions; /** Backdrop helper instance */ #backdrop: Backdrop | null = null; /** Page scroll lock shared with other sheets */ #scrollBar: ScrollBarHelper; /** Drag gesture controller (attached while shown) */ #dragController: DragController | null = null; // ==================== Component state ==================== /** State flags */ #state = { /** Whether the sheet is currently shown */ isShown: false, /** Whether a show/hide transition is in progress */ isTransitioning: false, }; /** Whether the component has been disposed */ #disposed = false; /** Total height of the sheet, measured when showing and on resize */ #sheetHeight = 0; // ==================== Detents ==================== /** Resting positions the sheet supports */ #detents: DetentModel; /** Detent the sheet is resting at */ #currentDetent: number; /** Whether the sheet currently presents modally (dimmed, inert, trapped) */ #modal = true; /** Observer keeping the sheet height in step with layout changes */ #resizeObserver: ResizeObserver | null = null; // ==================== Animation ==================== /** Spring animation driver */ #springAnimator = new SpringAnimator(); // ==================== Event handlers ==================== /** References to bound event handlers for easy removal */ #handlers: SheetHandlers = { escape: null, dismiss: null, }; // ==================== Focus and inert management ==================== /** Focus trap helper */ #focusTrap: FocusTrap; /** Inert manager for content outside the sheet */ #inert = new InertManager(); /** * Creates a new BootstrapSheet instance. * If an instance already exists for the given element, it is returned instead. * @param element - The sheet element or a selector string * @param config - Configuration options to override defaults * @throws {Error} If the element is not found or invalid * @throws {TypeError} If config has invalid property types */ constructor(element: HTMLElement | string, config: BootstrapSheetOptions = {}) { const resolvedElement = resolveElement(element); if (!(resolvedElement instanceof HTMLElement)) { throw new Error(`${NAME}: element not found`); } // Assign fields before the duplicate check so every constructor path // definitely initializes them; the placeholders are replaced below. this.#element = resolvedElement; this.#config = Default; this.#focusTrap = new FocusTrap({ trapElement: resolvedElement }); this.#scrollBar = new ScrollBarHelper(resolvedElement); this.#detents = new DetentModel(Default.detents); this.#currentDetent = this.#detents.smallest; // Prevent duplicate instances const existing = INSTANCES.get(resolvedElement); if (existing) { return existing; } // Merge configuration: defaults < data-attributes < config const dataConfig = extractDataAttributes(resolvedElement); const mergedConfig: Record = { ...Default, ...dataConfig, ...config }; // Data attributes arrive as strings; normalize before the type check mergedConfig.detents = parseDetents(mergedConfig.detents); validateConfigTypes(NAME, mergedConfig, DefaultType); this.#config = mergedConfig; this.#detents = new DetentModel(this.#config.detents); this.#currentDetent = this.#resolveInitialDetent(); this.#validateUndimmedDetent(); this.#setupAccessibility(); this.#warnDeprecatedDragHandle(); INSTANCES.set(resolvedElement, this); } // ==================== Public API ==================== /** * Get the component name * @returns The component name */ static get NAME(): string { return NAME; } /** * Get the default configuration * @returns The default configuration */ static get Default(): ResolvedSheetOptions { return Default; } /** * Get an instance associated with an element * @param element - The element or selector * @returns Instance or null */ static getInstance(element: HTMLElement | string): BootstrapSheet | null { const resolvedElement = resolveElement(element); return resolvedElement instanceof HTMLElement ? (INSTANCES.get(resolvedElement) ?? null) : null; } /** * Get or create an instance * @param element - The element or selector * @param config - Configuration options * @throws {Error} If element parameter is invalid * @throws {TypeError} If config has invalid property types * @returns The instance */ static getOrCreateInstance( element: HTMLElement | string, config: BootstrapSheetOptions = {}, ): BootstrapSheet { return this.getInstance(element) ?? new this(element, config); } /** * Check if the sheet is currently shown * @returns True if shown, false otherwise */ get isShown(): boolean { return this.#state.isShown; } /** * Check if the sheet is currently transitioning * @returns True if transitioning, false otherwise */ get isTransitioning(): boolean { return this.#state.isTransitioning; } /** * The detent the sheet is resting at. * * Reports the resting position, not the position in flight: during a drag or * an animation it still names where the sheet last settled. * @returns The current detent */ get currentDetent(): number { return this.#currentDetent; } /** * Animate the sheet to one of its configured detents * @param detent - A value from the `detents` option * @fires EVENT.DETENT_CHANGE */ setDetent(detent: number): void { if (this.#disposed || !this.#state.isShown || this.#state.isTransitioning) { return; } if (!this.#detents.has(detent)) { console.warn( `[${NAME}] setDetent() accepts a configured detent, but received ` + `${JSON.stringify(detent)}. Configured: ${JSON.stringify(this.#detents.list)}.`, ); return; } if (detent === this.#currentDetent) { return; } this.#animateToDetent(detent); } /** * Show the sheet * @fires EVENT.SHOW * @fires EVENT.SHOWN * @see {@link hide} * @see {@link toggle} */ show(): void { if (this.#disposed) { return; } if (this.#state.isShown || this.#state.isTransitioning) { return; } const showEvent = this.#triggerEvent(EVENT.SHOW); if (showEvent.defaultPrevented) { return; } this.#prepareShow(); this.#executeShow(); } /** * Hide the sheet * @fires EVENT.HIDE * @fires EVENT.HIDDEN * @see {@link show} * @see {@link toggle} */ hide(): void { if (this.#disposed) { return; } if (!this.#state.isShown || this.#state.isTransitioning) { return; } this.#requestHide(); } /** * Toggle the sheet visibility */ toggle(): void { if (this.#state.isShown) { this.hide(); } else { this.show(); } } /** * Dispose the component and clean up resources * @example * const sheet = new BootstrapSheet('#mySheet'); * // Later, when you want to remove the sheet * sheet.dispose(); */ dispose(): void { if (this.#disposed) { return; } this.#cleanup(); INSTANCES.delete(this.#element); this.#disposed = true; } // ==================== Private Methods: Setup ==================== /** * Set up accessibility attributes on the sheet element */ #setupAccessibility(): void { this.#element.setAttribute('role', 'dialog'); this.#element.setAttribute('aria-modal', 'true'); this.#element.setAttribute('tabindex', '-1'); } /** * Warn once about the drag handle attribute that no longer does anything */ #warnDeprecatedDragHandle(): void { if (!this.#element.querySelector(SELECTOR.DRAG_HANDLE)) { return; } console.warn( `[${NAME}] The "data-bs-drag="sheet"" attribute is deprecated and has no effect: ` + 'the whole sheet is draggable since v0.4.0. Remove the attribute; the ' + '"sheet-handle" element itself can stay as a visual grabber. ' + 'Use data-bs-drag="false" to mark areas that must not start a drag.', ); } // ==================== Private Methods: Open Sheets ==================== /** * Record the sheet as open, on top of all other open sheets */ #registerOpenSheet(): void { // Sheets removed from the document while open never closed; forget them BootstrapSheet.#openSheets = BootstrapSheet.#openSheets.filter( (openSheet) => openSheet !== this && openSheet.#element.isConnected, ); BootstrapSheet.#openSheets.push(this); } /** * Stop tracking the sheet as open */ #unregisterOpenSheet(): void { BootstrapSheet.#openSheets = BootstrapSheet.#openSheets.filter( (openSheet) => openSheet !== this, ); } /** * Whether the sheet is the open one on top of all others. Sheets removed * from the document while open are skipped. */ #isTopmostOpenSheet(): boolean { const connectedSheets = BootstrapSheet.#openSheets.filter( (openSheet) => openSheet.#element.isConnected, ); return connectedSheets[connectedSheets.length - 1] === this; } // ==================== Private Methods: Show ==================== /** * Prepare sheet state for showing */ #prepareShow(): void { this.#state.isShown = true; this.#state.isTransitioning = true; this.#registerOpenSheet(); this.#focusTrap.capture(); this.#currentDetent = this.#resolveInitialDetent(); this.#sheetHeight = this.#measureHeight(); } /** * Execute show animation and setup */ #executeShow(): void { if (this.#config.backdrop) { this.#createBackdrop(); } this.#modal = this.#isModalAt(this.#currentDetent); this.#applyModality(); this.#updateTouchAction(); this.#element.classList.add(CLASS_NAME.SHOWING); this.#attachEventHandlers(); this.#observeResize(); this.#animateSpring(this.#detents.positionOf(this.#currentDetent, this.#sheetHeight), 0, () => this.#finalizeShow(), ); } /** * Finalize show animation */ #finalizeShow(): void { this.#element.classList.remove(CLASS_NAME.SHOWING); this.#element.classList.add(CLASS_NAME.SHOW); this.#state.isTransitioning = false; if (this.#config.focus && this.#modal) { this.#focusTrap.focusInitial(); } this.#triggerEvent(EVENT.SHOWN); } // ==================== Private Methods: Hide ==================== /** * Ask listeners whether the sheet may close, and close it if they agree * @param initialVelocity - Velocity to start the close animation with (px/s) * @returns Whether the sheet started closing * @fires EVENT.HIDE */ #requestHide(initialVelocity = 0): boolean { const hideEvent = this.#triggerEvent(EVENT.HIDE); if (hideEvent.defaultPrevented) { return false; } this.#prepareHide(); this.#executeHide(initialVelocity); return true; } /** * Prepare sheet state for hiding */ #prepareHide(): void { this.#state.isShown = false; this.#state.isTransitioning = true; this.#unregisterOpenSheet(); if (this.#dragController?.isDragging) { this.#dragController.abort(); this.#cancelAnimations(); } if (this.#config.focus) { this.#focusTrap.deactivate(); } this.#element.classList.add(CLASS_NAME.HIDING); this.#element.classList.remove(CLASS_NAME.SHOW); } /** * Execute hide animation and cleanup * @param initialVelocity - Velocity to start the close animation with (px/s) */ #executeHide(initialVelocity = 0): void { this.#detachEventHandlers(); this.#disconnectResizeObserver(); this.#cancelAnimations(); this.#inert.remove(); this.#animateSpring(this.#sheetHeight, initialVelocity, () => this.#finalizeHide()); } /** * Finalize hide animation */ #finalizeHide(): void { this.#element.classList.remove(CLASS_NAME.HIDING); this.#element.style.transform = ''; this.#element.style.touchAction = ''; this.#state.isTransitioning = false; this.#removeBackdrop(); this.#scrollBar.reset(); this.#focusTrap.restore(); this.#triggerEvent(EVENT.HIDDEN); } // ==================== Private Methods: Backdrop ==================== /** * Create and show the backdrop via the Backdrop helper */ #createBackdrop(): void { const isStatic = this.#config.backdrop === 'static'; this.#backdrop = new Backdrop({ isStatic, onClick: () => (isStatic ? this.#shakeSheet() : this.hide()), }); this.#backdrop.show(); } /** * Remove backdrop element from DOM */ #removeBackdrop(): void { if (this.#backdrop) { this.#backdrop.dispose(); this.#backdrop = null; } } /** * Update backdrop opacity for a sheet position * @param position - Current translateY (px) */ #updateBackdropOpacity(position: number): void { this.#backdrop?.setOpacity(this.#opacityAt(position)); } /** * Backdrop opacity for a sheet position. * * Without `undimmedDetent` the backdrop tracks how open the sheet is, which * is the behavior every previous version had. With it, dimming is confined * to the range above that detent: the backdrop is fully transparent at and * below it, then ramps to opaque at the next detent up. * * @param position - Current translateY (px) * @returns Opacity in [0, 1] */ #opacityAt(position: number): number { const height = this.#sheetHeight; const undimmed = this.#config.undimmedDetent; if (undimmed === null) { // An unmeasured sheet counts as fully open, matching the drag ratio return clamp(1 - (height ? position / height : 0), 0, 1); } const dimmed = this.#detents.above(undimmed); if (dimmed === null) { return 0; } const undimmedY = this.#detents.positionOf(undimmed, height); const dimmedY = this.#detents.positionOf(dimmed, height); const span = undimmedY - dimmedY; return clamp(span ? (undimmedY - position) / span : 0, 0, 1); } // ==================== Private Methods: Event Handlers ==================== /** * Attach all necessary event handlers */ #attachEventHandlers(): void { this.#attachEscapeHandler(); this.#attachDismissHandlers(); if (this.#config.gestures) { this.#attachGestureHandlers(); } } /** * Detach all event handlers */ #detachEventHandlers(): void { this.#detachEscapeHandler(); this.#detachDismissHandlers(); if (this.#config.gestures) { this.#detachGestureHandlers(); } } /** * Attach Escape key handler to close the sheet. * * Only the sheet on top answers: the ones underneath stay open, even when * the top one ignores Escape. A keypress that something else already * handled, or one that belongs to an IME composition, is left alone. A * non-modal sheet leaves the page live, so Escape pressed on the page * belongs to the page. */ #attachEscapeHandler(): void { if (!this.#config.keyboard) { return; } this.#handlers.escape = (event: KeyboardEvent) => { if (event.key !== 'Escape' || event.defaultPrevented || event.isComposing) { return; } if (!this.#isTopmostOpenSheet()) { return; } if (!this.#modal && !this.#element.contains(document.activeElement)) { return; } if (this.#config.backdrop === 'static') { this.#shakeSheet(); } else { this.hide(); } }; document.addEventListener('keydown', this.#handlers.escape); } /** * Detach Escape key handler */ #detachEscapeHandler(): void { if (this.#handlers.escape) { document.removeEventListener('keydown', this.#handlers.escape); this.#handlers.escape = null; } } /** * Attach click handlers to dismiss buttons within the sheet */ #attachDismissHandlers(): void { const dismissButtons = this.#element.querySelectorAll(SELECTOR.DATA_DISMISS); const dismissHandler = (): void => this.hide(); this.#handlers.dismiss = dismissHandler; dismissButtons.forEach((button) => { button.addEventListener('click', dismissHandler); }); } /** * Detach click handlers from dismiss buttons */ #detachDismissHandlers(): void { const dismissHandler = this.#handlers.dismiss; if (!dismissHandler) { return; } const dismissButtons = this.#element.querySelectorAll(SELECTOR.DATA_DISMISS); dismissButtons.forEach((button) => { button.removeEventListener('click', dismissHandler); }); this.#handlers.dismiss = null; } /** * Animate shake effect for static backdrop via Web Animations API. * Uses composite: 'add' so the shake overlays the spring transform without interfering. */ #shakeSheet(): void { if (typeof this.#element.animate !== 'function') { return; } this.#element.animate( [ { transform: 'translateY(0px)' }, { transform: 'translateY(-4px)' }, { transform: 'translateY(4px)' }, { transform: 'translateY(-4px)' }, { transform: 'translateY(4px)' }, { transform: 'translateY(0px)' }, ], { duration: 600, easing: 'ease-in-out', composite: 'add' }, ); } // ==================== Private Methods: Gestures ==================== /** * Create and attach the drag controller for the sheet root. * The controller owns pointer input and gesture physics; DOM side effects * (transform, backdrop, class names, events, animations) happen here. */ #attachGestureHandlers(): void { this.#dragController = new DragController({ element: this.#element, getPosition: () => getTranslateY(this.#element), getSheetHeight: () => this.#sheetHeight, getTopBound: () => this.#detents.positionOf(this.#detents.largest, this.#sheetHeight), isAtLargestDetent: () => this.#currentDetent === this.#detents.largest, isEnabled: () => this.#state.isShown, onDragStart: () => this.#element.classList.add(CLASS_NAME.DRAGGING), onDragEnd: () => this.#element.classList.remove(CLASS_NAME.DRAGGING), onTakeover: () => this.#takeOver(), onMove: ({ adjustedY, deltaY, ratio, velocity }) => { this.#element.style.transform = `translateY(${adjustedY}px)`; this.#updateBackdropOpacity(adjustedY); this.#triggerEvent(EVENT.SLIDE, { velocity, adjustedY, deltaY, ratio }); }, onRelease: ({ projectedY, velocity }) => { const target = this.#detents.resolve(projectedY, this.#sheetHeight); if (target.detent === null) { // Listeners are asked before the sheet leaves the screen. A vetoed // dismissal settles at the nearest position the sheet may rest at: // the release was projected past the smallest detent, so that one. if (!this.#requestHide(velocity)) { this.#animateToDetent(this.#detents.smallest, velocity); } } else { this.#animateToDetent(target.detent, velocity); } }, onAbort: () => this.#animateToDetent(this.#currentDetent), }); this.#dragController.attach(); } /** * Hand the transform over to a drag gesture. * * A drag may begin while the open animation is still running. Cancelling the * spring drops its settle callback, so the show is completed here instead: * once the sheet is in the user's hands the open transition is over, and * leaving it pending would block `hide()` for good. */ #takeOver(): void { this.#cancelAnimations(); if (this.#state.isTransitioning) { this.#finalizeShow(); } } /** * Abort any active drag and detach the drag controller */ #detachGestureHandlers(): void { if (this.#dragController) { this.#dragController.abort(); this.#dragController.detach(); this.#dragController = null; } } // ==================== Private Methods: Detents ==================== /** * The detent the sheet opens at: the configured one, or the smallest * @throws {TypeError} If `initialDetent` is not one of the configured detents */ #resolveInitialDetent(): number { const initial = this.#config.initialDetent; if (initial === null) { return this.#detents.smallest; } if (!this.#detents.has(initial)) { throw new TypeError( `[${NAME}] Option "initialDetent" must be one of the configured detents ` + `${JSON.stringify(this.#detents.list)}, but received ${JSON.stringify(initial)}.`, ); } return initial; } /** * Reject an `undimmedDetent` that is not a detent, or that would leave the * sheet undimmed everywhere - a permanently non-modal sheet is a different * component contract, not a configuration of this one. * @throws {TypeError} If the option cannot produce a dimmed range */ #validateUndimmedDetent(): void { const undimmed = this.#config.undimmedDetent; if (undimmed === null) { return; } if (!this.#detents.has(undimmed)) { throw new TypeError( `[${NAME}] Option "undimmedDetent" must be one of the configured detents ` + `${JSON.stringify(this.#detents.list)}, but received ${JSON.stringify(undimmed)}.`, ); } if (undimmed === this.#detents.largest) { throw new TypeError( `[${NAME}] Option "undimmedDetent" must be smaller than the largest detent ` + `(${this.#detents.largest}), otherwise the sheet is never modal.`, ); } } /** * Animate to a detent and settle there * @param detent - Target detent * @param initialVelocity - Velocity at animation start (px/s) */ #animateToDetent(detent: number, initialVelocity = 0): void { const target = this.#detents.positionOf(detent, this.#sheetHeight); this.#animateSpring(target, initialVelocity, () => this.#settleAt(detent)); } /** * Record a settled detent and bring the sheet's state in line with it. * * Modality is toggled only here, never per animation frame: applying inert * walks the ancestor chain of the sheet, which is far too expensive to run * sixty times a second. * @param detent - The detent just settled on * @fires EVENT.DETENT_CHANGE */ #settleAt(detent: number): void { const previousDetent = this.#currentDetent; this.#currentDetent = detent; this.#updateTouchAction(); const modal = this.#isModalAt(detent); if (modal !== this.#modal) { this.#modal = modal; this.#applyModality(); } if (detent !== previousDetent) { this.#triggerEvent(EVENT.DETENT_CHANGE, { detent, previousDetent }); } } /** * Whether the sheet presents modally at a given detent * @param detent - Detent to test */ #isModalAt(detent: number): boolean { const undimmed = this.#config.undimmedDetent; return undimmed === null || detent > undimmed; } /** * Apply the current modality to the DOM. * * A non-modal sheet leaves the page behind usable in every sense: it still * scrolls, the backdrop is transparent to pointer events, the background is * not inert, focus is not trapped, and `aria-modal` says so. */ #applyModality(): void { this.#element.setAttribute('aria-modal', String(this.#modal)); this.#backdrop?.setInteractive(this.#modal); if (this.#modal) { this.#scrollBar.hide(); this.#inert.apply([this.#backdrop?.element, this.#element]); if (this.#config.focus) { this.#focusTrap.activate(); } } else { this.#scrollBar.reset(); this.#inert.remove(); this.#focusTrap.deactivate(); } } /** * Manage `touch-action` on the sheet root. * * Below the most open detent vertical panning belongs to the sheet alone, so * the content must not scroll natively. At the most open detent the * stylesheet value applies again and content scrolls as usual. */ #updateTouchAction(): void { const atLargest = this.#currentDetent === this.#detents.largest; this.#element.style.touchAction = atLargest ? '' : 'pan-x'; } // ==================== Private Methods: Layout ==================== /** * Measure the sheet's height */ #measureHeight(): number { return this.#element.offsetHeight || this.#element.getBoundingClientRect().height || 0; } /** * Track layout changes so detents stay where they belong. * * The height is captured when the sheet opens; a rotation, an on-screen * keyboard or a mobile address bar collapsing all change it afterwards, and * every detent is expressed as a fraction of it. */ #observeResize(): void { if (typeof ResizeObserver === 'undefined' || this.#resizeObserver) { return; } this.#resizeObserver = new ResizeObserver(() => this.#handleResize()); this.#resizeObserver.observe(this.#element); } /** * Stop tracking layout changes */ #disconnectResizeObserver(): void { if (this.#resizeObserver) { this.#resizeObserver.disconnect(); this.#resizeObserver = null; } } /** * Re-measure and re-place the sheet after a layout change. * * Skipped while a gesture or an animation owns the transform: correcting the * position underneath either one would fight it. */ #handleResize(): void { if ( !this.#state.isShown || this.#dragController?.isDragging || this.#springAnimator.isRunning ) { return; } const height = this.#measureHeight(); if (height === 0 || height === this.#sheetHeight) { return; } this.#sheetHeight = height; const position = this.#detents.positionOf(this.#currentDetent, height); this.#element.style.transform = `translateY(${position}px)`; this.#updateBackdropOpacity(position); } // ==================== Private Methods: Spring Animation ==================== /** * Resolve spring parameters from config * @returns Physical spring constants */ #resolveSpringParams() { return springParameters(this.#config.springDampingRatio, this.#config.springResponse); } /** * Animate sheet to target position using the spring driver. * The driver produces positions; applying them to the DOM (transform, * backdrop opacity, class names) happens here. * @param targetY - Target translateY position * @param initialVelocity - Velocity at animation start (px/s) * @param onComplete - Callback when animation settles */ #animateSpring(targetY: number, initialVelocity = 0, onComplete?: () => void): void { this.#cancelAnimations(); this.#element.classList.add(CLASS_NAME.ANIMATING); this.#springAnimator.start({ from: getTranslateY(this.#element), to: targetY, initialVelocity, params: this.#resolveSpringParams(), onFrame: (position) => { this.#element.style.transform = `translateY(${position}px)`; this.#updateBackdropOpacity(position); }, onSettle: () => { // Snap to exact target and clean up. The spring stops within half a // pixel of the target, so the backdrop has to be pinned as well or it // keeps the opacity of the last frame before that. this.#element.style.transform = `translateY(${targetY}px)`; this.#updateBackdropOpacity(targetY); this.#element.classList.remove(CLASS_NAME.ANIMATING); onComplete?.(); }, }); } // ==================== Private Methods: Utilities ==================== /** * Cancel the spring animation */ #cancelAnimations(): void { this.#springAnimator.cancel(); this.#element.classList.remove(CLASS_NAME.ANIMATING); } /** * Clean up component state and resources */ #cleanup(): void { // Reset state this.#state.isShown = false; this.#state.isTransitioning = false; this.#unregisterOpenSheet(); this.#scrollBar.reset(); this.#removeBackdrop(); this.#detachEventHandlers(); this.#disconnectResizeObserver(); this.#cancelAnimations(); this.#focusTrap.deactivate(); this.#inert.remove(); } /** * Trigger custom event on the sheet element * @param name - Event name * @param detail - Event detail object * @returns The triggered event */ #triggerEvent(name: string, detail: Record = {}): CustomEvent { const event = new CustomEvent(name, { detail, bubbles: true, cancelable: true, }); this.#element.dispatchEvent(event); return event; } } // ==================== Global Initialization ==================== // Server-side renderers import the module where there is no document if (typeof document !== 'undefined') { /** * Global click handler to toggle sheets via data attributes */ document.addEventListener('click', (event) => { if (!(event.target instanceof Element)) { return; } const trigger = event.target.closest(SELECTOR.DATA_TOGGLE); if (!trigger) { return; } event.preventDefault(); const targetSelector = extractTargetSelector(trigger); if (!targetSelector) { return; } const sheetElement = document.querySelector(targetSelector); if (!(sheetElement instanceof HTMLElement)) { return; } let sheetInstance = BootstrapSheet.getInstance(sheetElement); if (!sheetInstance) { // The constructor reads the sheet's own data attributes; only the // trigger's are passed, and they take precedence over the sheet's const triggerConfig: Record = extractDataAttributes(trigger); // Data attributes arrive as strings; normalize before the type check if ('detents' in triggerConfig) { triggerConfig.detents = parseDetents(triggerConfig.detents); } // Runtime-validates the parsed attributes and narrows them to typed options validateConfigTypes(NAME, triggerConfig, DefaultType); sheetInstance = new BootstrapSheet(sheetElement, triggerConfig); } sheetInstance.toggle(); }); } // ==================== Export ==================== export default BootstrapSheet; declare global { interface Window { BootstrapSheet: typeof BootstrapSheet; } } if (typeof window !== 'undefined') { window.BootstrapSheet = BootstrapSheet; }