/* This file is part of GNU Taler (C) 2022 Taler Systems S.A. GNU Taler is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see */ //////////////////// function ownerDocument(node: Node | null | undefined): Document { return (node && node.ownerDocument) || document; } function ownerWindow(node: Node | undefined): Window { const doc = ownerDocument(node); return doc.defaultView || window; } // A change of the browser zoom change the scrollbar size. // Credit https://github.com/twbs/bootstrap/blob/488fd8afc535ca3a6ad4dc581f5e89217b6a36ac/js/src/util/scrollbar.js#L14-L18 function getScrollbarSize(doc: Document): number { // https://developer.mozilla.org/en-US/docs/Web/API/Window/innerWidth#usage_notes const documentWidth = doc.documentElement.clientWidth; return Math.abs(window.innerWidth - documentWidth); } ///////////////////// export interface ManagedModalProps { disableScrollLock?: boolean; } // Is a vertical scrollbar displayed? function isOverflowing(container: Element): boolean { const doc = ownerDocument(container); if (doc.body === container) { return ownerWindow(container).innerWidth > doc.documentElement.clientWidth; } return container.scrollHeight > container.clientHeight; } export function ariaHidden(element: Element, show: boolean): void { if (show) { element.setAttribute("aria-hidden", "true"); } else { element.removeAttribute("aria-hidden"); } } function getPaddingRight(element: Element): number { return ( parseInt(ownerWindow(element).getComputedStyle(element).paddingRight, 10) || 0 ); } function ariaHiddenSiblings( container: Element, mountElement: Element, currentElement: Element, elementsToExclude: readonly Element[] = [], show: boolean, ): void { const blacklist = [mountElement, currentElement, ...elementsToExclude]; const blacklistTagNames = ["TEMPLATE", "SCRIPT", "STYLE"]; [].forEach.call(container.children, (element: Element) => { if ( blacklist.indexOf(element) === -1 && blacklistTagNames.indexOf(element.tagName) === -1 ) { ariaHidden(element, show); } }); } function findIndexOf( items: readonly T[], callback: (item: T) => boolean, ): number { let idx = -1; items.some((item, index) => { if (callback(item)) { idx = index; return true; } return false; }); return idx; } function handleContainer(containerInfo: Container, props: ManagedModalProps) { const restoreStyle: Array<{ /** * CSS property name (HYPHEN CASE) to be modified. */ property: string; el: HTMLElement | SVGElement; value: string; }> = []; const container = containerInfo.container; if (!props.disableScrollLock) { if (isOverflowing(container)) { // Compute the size before applying overflow hidden to avoid any scroll jumps. const scrollbarSize = getScrollbarSize(ownerDocument(container)); restoreStyle.push({ value: container.style.paddingRight, property: "padding-right", el: container, }); // Use computed style, here to get the real padding to add our scrollbar width. container.style.paddingRight = `${ getPaddingRight(container) + scrollbarSize }px`; // .mui-fixed is a global helper. const fixedElements = ownerDocument(container).querySelectorAll(".mui-fixed"); [].forEach.call(fixedElements, (element: HTMLElement | SVGElement) => { restoreStyle.push({ value: element.style.paddingRight, property: "padding-right", el: element, }); element.style.paddingRight = `${ getPaddingRight(element) + scrollbarSize }px`; }); } // Improve Gatsby support // https://css-tricks.com/snippets/css/force-vertical-scrollbar/ const parent = container.parentElement; const containerWindow = ownerWindow(container); const scrollContainer = parent?.nodeName === "HTML" && containerWindow.getComputedStyle(parent).overflowY === "scroll" ? parent : container; // Block the scroll even if no scrollbar is visible to account for mobile keyboard // screensize shrink. restoreStyle.push( { value: scrollContainer.style.overflow, property: "overflow", el: scrollContainer, }, { value: scrollContainer.style.overflowX, property: "overflow-x", el: scrollContainer, }, { value: scrollContainer.style.overflowY, property: "overflow-y", el: scrollContainer, }, ); scrollContainer.style.overflow = "hidden"; } const restore = () => { restoreStyle.forEach(({ value, el, property }) => { if (value) { el.style.setProperty(property, value); } else { el.style.removeProperty(property); } }); }; return restore; } function getHiddenSiblings(container: Element) { const hiddenSiblings: Element[] = []; [].forEach.call(container.children, (element: Element) => { if (element.getAttribute("aria-hidden") === "true") { hiddenSiblings.push(element); } }); return hiddenSiblings; } interface Modal { mount: Element; modalRef: Element; } interface Container { container: HTMLElement; hiddenSiblings: Element[]; modals: Modal[]; restore: null | (() => void); } export class ModalManager { private containers: Container[]; private modals: Modal[]; constructor() { this.modals = []; this.containers = []; } add(modal: Modal, container: HTMLElement): number { let modalIndex = this.modals.indexOf(modal); if (modalIndex !== -1) { return modalIndex; } modalIndex = this.modals.length; this.modals.push(modal); // If the modal we are adding is already in the DOM. if (modal.modalRef) { ariaHidden(modal.modalRef, false); } const hiddenSiblings = getHiddenSiblings(container); ariaHiddenSiblings( container, modal.mount, modal.modalRef, hiddenSiblings, true, ); const containerIndex = findIndexOf( this.containers, (item) => item.container === container, ); if (containerIndex !== -1) { this.containers[containerIndex].modals.push(modal); return modalIndex; } this.containers.push({ modals: [modal], container, restore: null, hiddenSiblings, }); return modalIndex; } mount(modal: Modal, props: ManagedModalProps): void { const containerIndex = findIndexOf( this.containers, (item) => item.modals.indexOf(modal) !== -1, ); const containerInfo = this.containers[containerIndex]; if (!containerInfo.restore) { containerInfo.restore = handleContainer(containerInfo, props); } } remove(modal: Modal): number { const modalIndex = this.modals.indexOf(modal); if (modalIndex === -1) { return modalIndex; } const containerIndex = findIndexOf( this.containers, (item) => item.modals.indexOf(modal) !== -1, ); const containerInfo = this.containers[containerIndex]; containerInfo.modals.splice(containerInfo.modals.indexOf(modal), 1); this.modals.splice(modalIndex, 1); // If that was the last modal in a container, clean up the container. if (containerInfo.modals.length === 0) { // The modal might be closed before it had the chance to be mounted in the DOM. if (containerInfo.restore) { containerInfo.restore(); } if (modal.modalRef) { // In case the modal wasn't in the DOM yet. ariaHidden(modal.modalRef, true); } ariaHiddenSiblings( containerInfo.container, modal.mount, modal.modalRef, containerInfo.hiddenSiblings, false, ); this.containers.splice(containerIndex, 1); } else { // Otherwise make sure the next top modal is visible to a screen reader. const nextTop = containerInfo.modals[containerInfo.modals.length - 1]; // as soon as a modal is adding its modalRef is undefined. it can't set // aria-hidden because the dom element doesn't exist either // when modal was unmounted before modalRef gets null if (nextTop.modalRef) { ariaHidden(nextTop.modalRef, false); } } return modalIndex; } isTopModal(modal: Modal): boolean { return ( this.modals.length > 0 && this.modals[this.modals.length - 1] === modal ); } }