/* eslint-disable ts/no-this-alias */ /* eslint-disable no-useless-call */ import type { VNode } from 'vue'; import type { ModalOnBeforeShownArgs, ModalOnShownArgs } from './modal-utils'; import { Modal as BootstrapModal } from 'bootstrap'; import { Prop, toNative } from 'vue-facing-decorator'; import PowerduckState from '../../app/powerduck-state'; import TsxComponent, { Component } from '../../app/vuetsx'; import DropdownUtils from '../../common/utils/dropdown-utils'; import { isNullOrEmpty } from '../../common/utils/is-null-or-empty'; import { PortalUtils } from '../../common/utils/utils'; import LoadingIndicator from '../loading-indicator'; import Teleport from '../teleport/teleport'; import ModalAnimationError from './animation-error'; import ModalAnimationSuccess from './animation-success'; import ModalIconWarning from './icon-warning'; import { ModalConfig, ModalMobileMode } from './modal-config'; import { ModalUtils } from './modal-utils'; import './css/modal.css'; interface ModalArgs { title: string | VNode; backdropCssClass?: string; size?: ModalSize; icon?: ModalHeaderIcon; blocked?: boolean; cssClass?: string; mobileMode?: ModalMobileMode; dismissable?: boolean; backdropStatic?: boolean; modalLazyMode?: boolean; preventHistoryEntry?: boolean; portalTarget?: string; ariaRole?: 'dialog' | 'alertdialog'; } export enum ModalSize { Normal = 0, Small = 1, Large = 2, ExtraLarge = 3, FullWidth = 4, NormalToLarge = 5, SmallToNormal = 6, } export enum ModalHeaderIcon { Success = 0, Error = 1, Warning = 2, Question = 3, } declare global { export interface ModalShowArgs { onHidden?: () => void; onShown?: (e: ModalOnShownArgs) => void; onBeforeShown?: (e: ModalOnBeforeShownArgs) => void; } } export { ModalConfig, ModalMobileMode } from './modal-config'; // Module-scoped stack of currently open modal IDs. Top of stack handles ESC. // See QA__G-159: closes modals on ESC without activating Bootstrap's _enforceFocus(), // which would break Select2 / portal-rendered widgets inside admin modals. const openModalsStack: string[] = []; @Component class ModalComponent extends TsxComponent implements ModalArgs { @Prop() title!: string | VNode; @Prop() backdropCssClass!: string; @Prop() size!: ModalSize; @Prop() icon!: ModalHeaderIcon; @Prop() blocked!: boolean; @Prop() cssClass!: string; @Prop() dismissable: boolean; @Prop() backdropStatic!: boolean; @Prop() mobileMode?: ModalMobileMode; @Prop() modalLazyMode: boolean; @Prop() preventHistoryEntry: boolean; @Prop() ariaRole?: 'dialog' | 'alertdialog'; uuid: string = null; modalShown: boolean = false; /** * Mobile presentation css pinned at show() time. The presentation decision * (bottom sheet vs default) must NOT be recomputed while the modal is open: * if a mid-show re-render produces a different class string, Vue patches the * class attribute wholesale and wipes Bootstrap's runtime `show` class — * leaving an invisible display:block modal over a live backdrop. Refreshed * on every show(), so the next open picks up the current viewport again. */ private activeMobileModeCss: string | null = null; private handleEscapeKeydown: ((e: KeyboardEvent) => void) | null = null; private handleModalShown: (() => void) | null = null; private handleModalHidden: (() => void) | null = null; mounted() { this.uuid = PortalUtils.randomString(6); } beforeUnmount() { this.detachEscapeListener(); this.detachModalLifecycleListeners(); ModalUtils.destroyModalInstance(this.$refs.modalRoot as Element); } private getFullId(): string { return `modal-` + `-${this.uuid}`; } private getLabelId(): string { return `${this.getFullId()}label`; } private getModalSizeCss(): string { if (this.size == ModalSize.Large) { return ' modal-lg'; } else if (this.size == ModalSize.Small) { return ' modal-sm'; } else if (this.size == ModalSize.ExtraLarge) { return ' modal-xl'; } else if (this.size == ModalSize.FullWidth) { return ' modal-fw'; } else if (this.size == ModalSize.NormalToLarge) { return ' modal-ntl'; } else if (this.size == ModalSize.SmallToNormal) { return ' modal-stn'; } else { return ''; } } private getModalMobileModeCss(): string { if (this.treatMobileAsBottomSheet()) { return ' modal-bottom-sheet'; } else if (this.treatMobileAsFullScreen()) { return ' modal-mobile-fullscreen'; } return ''; } private handleMobileModeDragBinding() { if (!this.treatMobileAsBottomSheet()) { return; } ModalUtils.bindModalBottomSheetHandle(() => this.$refs.modalRoot as HTMLElement, () => this.hide()); } treatMobileAsBottomSheet(): boolean { return ModalConfig.useBottomSheet(this.mobileMode); } treatMobileAsFullScreen(): boolean { return (this.mobileMode == ModalMobileMode.FullScreen || (this.mobileMode == null && ModalConfig.defaultMobileMode == ModalMobileMode.FullScreen)); } getModalInstance(): BootstrapModal { return BootstrapModal.getOrCreateInstance(this.$refs.modalRoot as Element); } public show(args?: ModalShowArgs) { args = args || {}; this.modalShown = true; this.activeMobileModeCss = this.getModalMobileModeCss(); // This modal instance is reused (modalShown is never reset to false), so a // previous interaction may have left an orphaned datatable dropdown overflow // clone behind — e.g. when the task table re-rendered under polling and broke // the clone's close-event link. Purge any such stale clone before showing so a // reopened modal never surfaces a dropdown that looks "still open". DropdownUtils.disposeAllClones(); // Attach the ESC listener as early as possible — synchronously during show() — so that // even if the user presses ESC before Bootstrap's animation completes, our handler is // already registered. Re-attached in show.bs.modal as a backup; idempotent (handleEscapeKeydown // guard prevents double registration). this.attachEscapeListener(); this.$nextTick(() => { if (!isNullOrEmpty(this.backdropCssClass)) { const instance: any = this.getModalInstance(); if (instance._backdrop?._config) { instance._backdrop._config.className = `modal-backdrop ${this.backdropCssClass}`; } } this.attachModalLifecycleListeners(); ModalUtils.showModal(this, { modal: this.getModalInstance(), onShown: args.onShown, onHidden: args.onHidden, onBeforeShown: args.onBeforeShown, }); this.handleMobileModeDragBinding(); }); } public hide() { // Detach immediately to mirror show()'s eager attach. Also covered by hidden.bs.modal, // but defensive cleanup avoids a stale listener if hide() is called bypassing Bootstrap. this.detachEscapeListener(); ModalUtils.hideModal({ modal: this.getModalInstance(), }); } private attachModalLifecycleListeners() { const modalEl = this.$refs.modalRoot as HTMLElement | null; if (!modalEl) { return; } // Detach any previous listeners before re-attaching (defensive — show() can be called multiple times). this.detachModalLifecycleListeners(); const self = this; const nonStaticBackdrop = this.backdropStatic != true && this.dismissable != false; // Only register ESC handling for modals that allow keyboard dismiss (matches data-bs-keyboard semantics). if (!nonStaticBackdrop) { return; } this.handleModalShown = () => { self.attachEscapeListener.call(self); }; this.handleModalHidden = () => { self.detachEscapeListener.call(self); }; // `show.bs.modal` fires synchronously when modal.show() is called, before the fade-in // animation. Using this rather than `shown.bs.modal` avoids a race where the user presses // ESC during the 300ms fade-in and the listener isn't yet attached. modalEl.addEventListener('show.bs.modal', this.handleModalShown); modalEl.addEventListener('hidden.bs.modal', this.handleModalHidden); } private detachModalLifecycleListeners() { const modalEl = this.$refs.modalRoot as HTMLElement | null; if (modalEl != null && this.handleModalShown != null) { modalEl.removeEventListener('show.bs.modal', this.handleModalShown); } if (modalEl != null && this.handleModalHidden != null) { modalEl.removeEventListener('hidden.bs.modal', this.handleModalHidden); } this.handleModalShown = null; this.handleModalHidden = null; } private attachEscapeListener() { // Skip if keyboard dismiss is disabled (matches data-bs-keyboard="false" semantics). const nonStaticBackdrop = this.backdropStatic != true && this.dismissable != false; if (!nonStaticBackdrop) { return; } const modalId = this.getFullId(); // Push this modal onto the stack — only the top of the stack handles ESC. if (!openModalsStack.includes(modalId)) { openModalsStack.push(modalId); } // Already registered. if (this.handleEscapeKeydown != null) { return; } const self = this; this.handleEscapeKeydown = (e: KeyboardEvent) => { if (e.key !== 'Escape') { return; } // Only the topmost open modal should respond to ESC, so stacked modals don't double-close. if (openModalsStack[openModalsStack.length - 1] !== modalId) { return; } // If any portal-rendered widget (Select2, smart-dropdown, Bootstrap dropdown, // autocomplete, datepicker) is open, let it handle ESC first. Closing the modal at // the same time would surprise the user. // // We check this in the capture phase (see addEventListener call below) so the // widget's bubble-phase ESC handler hasn't yet run — meaning open-state classes // are still in the DOM and detectable here. if (self.isPortalWidgetOpen.call(self)) { return; } self.hide.call(self); }; // `capture: true` so this fires BEFORE widget-level bubble listeners (Select2 closes its // dropdown on bubble-phase ESC; if we listened in bubble phase, we'd race against it and // see `.select2-container--open` already removed). Capture lets us inspect DOM state // before any inner widget has had a chance to react to this same keydown. document.addEventListener( 'keydown', this.handleEscapeKeydown, true, ); } private isPortalWidgetOpen(): boolean { // New DropdownList panel: a `.pd-dd-panel` is in the DOM only while open. if (document.querySelector('.pd-dd-panel') != null) { return true; } // Smart-dropdown (powerduck) renders `.filter-dropdown` only when open. if (document.querySelector('.filter-dropdown') != null) { return true; } // Bootstrap-native dropdown. if (document.querySelector('.dropdown-menu.show') != null) { return true; } return false; } private detachEscapeListener() { if (this.handleEscapeKeydown != null) { // Must match the `capture: true` used at addEventListener time. document.removeEventListener( 'keydown', this.handleEscapeKeydown, true, ); this.handleEscapeKeydown = null; } const modalId = this.getFullId(); const idx = openModalsStack.indexOf(modalId); if (idx !== -1) { openModalsStack.splice(idx, 1); } } render() { const nonStaticBackdrop = this.backdropStatic != true && this.dismissable != false; if (!this.modalShown && this.modalLazyMode != false) { return null; } // QA__G-159: Always set data-bs-keyboard="false" so Bootstrap doesn't register its // own ESC handler on the modal element. We handle ESC ourselves via a document-level // keydown listener (see attachEscapeListener), which gives us reliable behavior: // - ESC works even when focus is on (default state right after modal opens). // - Portal-rendered widgets inside the modal (Select2, smart-dropdown, etc.) get to // handle ESC first; we only close the modal when no such widget is open. // - Doesn't trigger Bootstrap's _enforceFocus() which would break Select2's search input. return ( ); } renderModalHeaderIcon() { if (ModalConfig.renderModalHeaderIcon != null) { return ModalConfig.renderModalHeaderIcon(this.icon); } if (this.icon == ModalHeaderIcon.Warning) { return ; } else if (this.icon == ModalHeaderIcon.Success) { return ; } else if (this.icon == ModalHeaderIcon.Error) { return ; } else if (this.icon == ModalHeaderIcon.Question) { return ; } return null; } } const Modal = toNative(ModalComponent); export type ModalType = typeof Modal.prototype; export default Modal;