import type { Vue } from 'vue-facing-decorator'; import { Modal as BootstrapModal } from 'bootstrap'; import { globalState } from '../../app/global-state'; type VueType = typeof Vue.prototype; interface IVue extends VueType { } type ModalTarget = BootstrapModal | HTMLElement | Element; interface ModalDisplayArgs { modal: ModalTarget; data?: T; onHidden?: (response: ModalResponseData) => void; onShown?: (e: ModalOnShownArgs) => void; onBeforeShown?: (e: ModalOnBeforeShownArgs) => void; } interface ModalHideArgs { modal: ModalTarget; } interface ModalResponseData { data?: T; } export interface ModalOnBeforeShownArgs { instance: IVue; } export interface ModalOnShownArgs { instance: IVue; } export interface OnBeforeShownHook { (args: { modalHandler: BootstrapModal; modalElement: HTMLElement }): void; } interface ModalLifecycleBinding { bound: boolean; onShown: (() => void) | null; onHidden: (() => void) | null; } // Per-element store of the latest onShown/onHidden callbacks. We bind the // element listeners exactly once (lazily) and re-dispatch to whichever // callback was most recently registered, which mirrors the jQuery pattern of // `.off('hidden.bs.modal').on('hidden.bs.modal', ...)` without needing to keep // function references around. const lifecycleBindings = new WeakMap(); const ensureLifecycleListeners = (el: HTMLElement): ModalLifecycleBinding => { let binding = lifecycleBindings.get(el); if (binding == null) { binding = { bound: false, onShown: null, onHidden: null }; lifecycleBindings.set(el, binding); } if (!binding.bound) { binding.bound = true; el.addEventListener('shown.bs.modal', () => { binding!.onShown?.(); }); el.addEventListener('hidden.bs.modal', () => { binding!.onHidden?.(); }); } return binding; }; const resolveModal = (target: ModalTarget): { instance: BootstrapModal; element: HTMLElement } | null => { if (target == null) { return null; } // Bootstrap's Modal instance exposes `_element` (the underlying HTMLElement). const maybeBootstrap = target as any; if (maybeBootstrap._element instanceof HTMLElement) { return { instance: maybeBootstrap as BootstrapModal, element: maybeBootstrap._element }; } if (target instanceof HTMLElement) { return { instance: BootstrapModal.getOrCreateInstance(target), element: target }; } if (target instanceof Element) { return { instance: BootstrapModal.getOrCreateInstance(target), element: target as HTMLElement }; } return null; }; export class ModalUtils { private static _onBeforeShownHandlers: OnBeforeShownHook[]; static addOnBeforeShownHandler(handler: OnBeforeShownHook) { if (ModalUtils._onBeforeShownHandlers == null) { ModalUtils._onBeforeShownHandlers = []; } ModalUtils._onBeforeShownHandlers.push(handler); } static showModal(instance: IVue, dataArgs: ModalDisplayArgs | IVue | Element): void { let args: ModalDisplayArgs = dataArgs as any; if (args.modal == null) { args = { modal: args as any } as ModalDisplayArgs; } const resolved = resolveModal(args.modal); if (resolved == null) { console.error('Modal context not specified'); return; } let nextTick = instance?.$nextTick; if (nextTick == null) { nextTick = (cb) => { cb(); }; } nextTick(() => { const { instance: modalHandler, element: modalElement } = resolved; const binding = ensureLifecycleListeners(modalElement); binding.onHidden = args.onHidden ? () => args.onHidden!({ data: args.data }) : null; binding.onShown = args.onShown ? () => args.onShown!({ instance }) : null; if (args.onBeforeShown != null) { args.onBeforeShown({ instance }); } if (ModalUtils._onBeforeShownHandlers) { for (const hook of ModalUtils._onBeforeShownHandlers) { hook({ modalHandler, modalElement }); } } modalHandler.show(); }); } static hideModal(dataArgs: ModalHideArgs | IVue | Element): void { let args: ModalHideArgs = dataArgs as any; if (args.modal == null) { args = { modal: args as any } as ModalHideArgs; } const resolved = resolveModal(args.modal); if (resolved == null) { console.error('Modal context not found'); return; } setTimeout(() => { resolved.instance.hide(); }, 1); } static hideAllOpenModals(): void { const allModals = document.querySelectorAll('.modal.show'); if (allModals.length > 0) { for (let i = 0; i < allModals.length; i++) { const element = allModals[i]; ModalUtils.hideModal(element); } } } static destroyModalInstance(element: string | Element) { if (element == null || !globalState.windowExists) { return; } const targetElement: Element | null = typeof element === 'string' ? document.querySelector(element) : element; if (targetElement == null) { return; } // Clear our cached onShown/onHidden so a future showModal() doesn't fire // stale callbacks against a disposed component. const binding = lifecycleBindings.get(targetElement as HTMLElement); if (binding != null) { binding.onShown = null; binding.onHidden = null; } const modalInstance = BootstrapModal.getInstance(targetElement); if (modalInstance == null) { return; } targetElement.classList.remove('show'); const scrollBar = (modalInstance as any)._scrollBar; modalInstance.dispose(); const openModal = document.querySelectorAll('.modal.show'); if (openModal.length == 0) { document.body.classList.remove('modal-open'); scrollBar?.reset(); } } static handleModalMountDelay(instance: IVue, cb: () => void) { if (instance?.$el?.closest('.modal-dialog')) { instance.$nextTick(() => { instance.$nextTick(() => { setTimeout(() => { cb(); }, 100); }); }); } else { cb(); } } static bindModalBottomSheetHandle(getModal: () => HTMLElement, hideModal: () => void) { if ((getModal() as any)?._dragBound) { return; } setTimeout(() => { const modal = getModal() as HTMLElement; const modalDialog = modal.querySelector('.modal-dialog') as HTMLElement; const dragHandle = modal.querySelector('.drag-handle'); let startY = 0; let currentY = 0; let isDragging = false; const threshold = 100; const cancelDragging = () => { isDragging = false; modal.classList.remove('modal-is-dragging'); }; const startDragging = () => { isDragging = true; modal.classList.add('modal-is-dragging'); }; const onMove = (y) => { const translateY = Math.max(y - startY, 0); modalDialog.style.transform = `translateY(${translateY}px)`; }; const endDrag = () => { cancelDragging(); const dragDistance = currentY - startY; if (dragDistance > threshold) { hideModal(); // Bootstrap silently ignores hide() while a show/hide transition is running // (_isTransitioning) — e.g. when the user catches the sheet mid slide-up and // flings it down. The dialog would then stay stranded off-screen on the inline // drag transform, under a live backdrop (blur-locked screen). Once transitions // have settled, retry the hide if the drag transform is still applied; a // successful hide resets the transform via hidden.bs.modal, so a legitimately // re-shown modal is never closed by this retry. setTimeout(() => { if (modal.classList.contains('show') && modalDialog.style.transform !== '') { hideModal(); } }, 400); } else { modalDialog.style.transform = ''; } }; // Touch events dragHandle.addEventListener('touchstart', (e) => { startY = (e as TouchEvent).touches[0].clientY; // A tap fires no touchmove — without this reset, currentY kept the previous // gesture's end position and a mere tap could read as a >threshold "drag". currentY = startY; startDragging(); }); dragHandle.addEventListener('touchmove', (e) => { if (!isDragging) { return; } currentY = (e as TouchEvent).touches[0].clientY; onMove(currentY); }); dragHandle.addEventListener('touchend', endDrag); // Mouse events dragHandle.addEventListener('mousedown', (e) => { startY = (e as MouseEvent).clientY; // Same stale-currentY guard as touchstart: a click without mousemove must // compute a zero drag distance, not inherit the previous gesture's end. currentY = startY; startDragging(); const onMouseMove = (e) => { if (!isDragging) { return; } currentY = e.clientY; onMove(currentY); e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); }; const onMouseUp = () => { endDrag(); globalState.removeEventListener('mousemove', onMouseMove); globalState.removeEventListener('mouseup', onMouseUp); }; globalState.addEventListener('mousemove', onMouseMove); globalState.addEventListener('mouseup', onMouseUp); }); // Reset on modal hide modal.addEventListener('hidden.bs.modal', () => { cancelDragging(); modalDialog.style.transform = ''; }); (modal as any)._dragBound = true; }, 100); } }