summaryrefslogtreecommitdiff
path: root/packages/taler-wallet-webextension/src/mui/ModalManager.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/taler-wallet-webextension/src/mui/ModalManager.ts')
-rw-r--r--packages/taler-wallet-webextension/src/mui/ModalManager.ts328
1 files changed, 328 insertions, 0 deletions
diff --git a/packages/taler-wallet-webextension/src/mui/ModalManager.ts b/packages/taler-wallet-webextension/src/mui/ModalManager.ts
new file mode 100644
index 000000000..eee037467
--- /dev/null
+++ b/packages/taler-wallet-webextension/src/mui/ModalManager.ts
@@ -0,0 +1,328 @@
+/*
+ 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 <http://www.gnu.org/licenses/>
+ */
+
+////////////////////
+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<T>(
+ 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
+ );
+ }
+}