import A11yDialog from "a11y-dialog"; import { isTabbable } from "tabbable"; interface ModalConfig { /** Class which indicates that modal is open */ classModalIsOpen: string; /** Class on body which indicates that modal is open */ classModalIsOpenBody: string; /** Root of page content which should be hidden when modal is open */ root: string; /** Move modal into this element selector (must be unique in DOM) */ modalsRoot: string; /** Disable moving modal to #root-modals (for React controlled mode) */ disablePortal?: boolean; } const defaultConfig = (): ModalConfig => ({ /** Class which indicates that modal is open */ classModalIsOpen: "is-active", /** Class on body which indicates that modal is open */ classModalIsOpenBody: "has-modal", /** Root of page content which should be hidden when modal is open */ root: "#root", /** Move modal into this element selector (must be unique in DOM) */ modalsRoot: "#root-modals", }); const MODAL_SCROLLBAR_WIDTH_VAR = "--ods-modal-scrollbar-width"; const BODY_LOCK_SCROLL_TOP_ATTR = "data-lock-scrolltop"; interface HTMLElementWithModal extends HTMLElement { ODS_Modal?: Modal; } export default class Modal { element: HTMLElementWithModal; config: ModalConfig; instance!: A11yDialog; // Using definite assignment assertion as it's initialized in init() constructor(element: HTMLElement, config?: Partial) { this.element = element as HTMLElementWithModal; this.config = { ...defaultConfig(), ...config }; this.handleShow = this.handleShow.bind(this); this.handleHide = this.handleHide.bind(this); this.show = this.show.bind(this); this.hide = this.hide.bind(this); this.headerFirstItemSpacing = this.headerFirstItemSpacing.bind(this); Modal.moveToModalRoot = Modal.moveToModalRoot.bind(this); Modal.lockBody = Modal.lockBody.bind(this); Modal.unlockBody = Modal.unlockBody.bind(this); this.element.ODS_Modal = this; this.init(); return this; } static getInstance(el: HTMLElement | null): Modal | null { if (!el) return null; const modalEl = el as HTMLElementWithModal; return modalEl.ODS_Modal || null; } handleShow(el: HTMLElement): void { Modal.lockBody(); if (el) el.classList.add(this.config.classModalIsOpen); // Long modals can have first focusabble element under the fold, so static content above the fold should be focused instead to prevent undesirable scrolling const initialFocusEl = el.querySelector("[data-a11y-dialog-initial-focus]"); if (!initialFocusEl) { return; } // set tabindex to -1 so element can't be focused via keyboard if (!isTabbable(initialFocusEl as HTMLElement)) { initialFocusEl.setAttribute("tabindex", "-1"); } (initialFocusEl as HTMLElement).focus(); } handleHide(el: HTMLElement): void { Modal.unlockBody(); if (el) el.classList.remove(this.config.classModalIsOpen); } show(): void { this.instance.show(); } hide(): void { this.instance.hide(); } headerFirstItemSpacing(): void { /** * Target the first element which is not .btn inside .modal__header and add margin-right 40px. * css has its limitations and it is not possible with pseudo selectors */ const modalHeader = this.element.querySelector(".modal__header"); if (modalHeader) { const hasNoSpacingClass = modalHeader.classList.contains( "modal__header--no-spacing", ); const firstNonBtnElement = Array.from(modalHeader.children).find( (child) => !child.classList.contains("btn"), ); if (firstNonBtnElement && !hasNoSpacingClass) { (firstNonBtnElement as HTMLElement).style.marginRight = "40px"; } } } init(): void { if (this.config.modalsRoot && !this.config.disablePortal) { Modal.moveToModalRoot( this.element, document.querySelector(this.config.modalsRoot), ); } this.instance = new A11yDialog(this.element); this.instance.on("show", (event: Event) => { const dialogEl = event.currentTarget as HTMLElement; this.handleShow(dialogEl); }); this.instance.on("hide", (event: Event) => { const dialogEl = event.currentTarget as HTMLElement; this.handleHide(dialogEl); }); this.headerFirstItemSpacing(); } destroy(): void { if (this.instance) { // Don't call this.instance.destroy() because a11y-dialog's destroy // does replaceWith(cloneNode(true)) which breaks React! // Instead, just hide and let the instance be garbage collected this.instance.hide(); // Cast to any to access private methods for cleanup const dialog = this.instance as any; document.removeEventListener("click", dialog.handleTriggerClicks, true); document.body.removeEventListener("focus", dialog.maintainFocus, true); this.element.removeEventListener("keydown", dialog.bindKeypress, true); // Make sure scroll is unlocked when destroying Modal.unlockBody(); } this.element.ODS_Modal = this; } update(): void { this.destroy(); this.init(); } static moveToModalRoot(el: HTMLElement, container: Element | null): void { if (container) { container.appendChild(el); } else { console.warn( `\`modalsRoot: ${(this as unknown as Modal).config?.modalsRoot}\` element is not present in DOM. Modal will be placed inside content which can affect it's styling. Please provide \`modalsRoot\` selector (should be placed outside of main contant, usualy in end of tag)`, ); } } // Track if body is locked private static isLocked = false; private static resolveBodyLockClassName( className?: string, context?: unknown, ): string { const modalContext = context as Modal | undefined; return ( className || modalContext?.config?.classModalIsOpenBody || defaultConfig().classModalIsOpenBody ); } static lockBody(className?: string, root?: string): void { if (Modal.isLocked) return; const actualClassName = Modal.resolveBodyLockClassName(className, this); // Store scroll position const scrollY = window.scrollY; document.body.setAttribute(BODY_LOCK_SCROLL_TOP_ATTR, scrollY.toString()); const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; // Lock body with position fixed and offset document.body.style.position = "fixed"; document.body.style.top = `-${scrollY}px`; document.body.style.left = "0"; document.body.style.right = "0"; document.body.style.paddingRight = `${scrollbarWidth}px`; document.body.style.setProperty( MODAL_SCROLLBAR_WIDTH_VAR, `${scrollbarWidth}px`, ); // add modal class document.body.classList.add(actualClassName); Modal.isLocked = true; } static unlockBody(className?: string, root?: string): void { if (!Modal.isLocked) return; const actualClassName = Modal.resolveBodyLockClassName(className, this); const scrollTop = Number.parseInt( document.body.getAttribute(BODY_LOCK_SCROLL_TOP_ATTR) || "0", 10, ); // Remove lock styles document.body.style.position = ""; document.body.style.top = ""; document.body.style.left = ""; document.body.style.right = ""; document.body.style.paddingRight = ""; document.body.style.removeProperty(MODAL_SCROLLBAR_WIDTH_VAR); // remove modal class document.body.classList.remove(actualClassName); // Restore scroll position instantly (ignore scroll-behavior: smooth) window.scrollTo({ top: Number.isFinite(scrollTop) ? scrollTop : 0, left: 0, behavior: "instant", }); Modal.isLocked = false; } }