interface FloatingOptions { autoClose: boolean; top?: string; left?: string; right?: string; bottom?: string; innerClickTest?: Function; backClickListener?: Function; isFixed?: boolean; useOverlay?: boolean; overlayClassName?: string; } class FloatingEntry { private weakRefParent?: WeakRef; private weakRefContent?: WeakRef; private strongRefParent?: HTMLElement; private strongRefContent?: HTMLElement; public options: FloatingOptions; constructor(parent: HTMLElement, content: HTMLElement, opts: FloatingOptions) { try{ this.weakRefParent = new WeakRef(parent); this.weakRefContent = new WeakRef(content); } catch(error){ this.strongRefParent = parent; this.strongRefContent = content; } this.options = opts; } /** * * Obtém o elemento HTML pai associado ao objeto. * * @returns - Elemento HTML pai. * */ public get parent():HTMLElement|undefined{ if(this.weakRefParent){ return this.weakRefParent.deref(); } return this.strongRefParent } /** * * Obtém o elemento HTML de conteúdo associado ao objeto. * * @returns - Elemento HTML de conteúdo associado. * */ public get content():HTMLElement|undefined{ if(this.weakRefContent){ return this.weakRefContent.deref(); } return this.strongRefContent; } } /** * `FloatingManager`: Gerenciador de elementos flutuantes na tela. */ export default class FloatingManager { public static MODAL_DEFAULT_CLASSNAME = "FloatingManager__modal"; public static MODAL_ELEMENT_ID = "FloatingManager__overlay"; public static STYLE_ELEMENT_ID = "FloatingManager__style"; public static DATA_OLD_ZINDEX_ATTRIBUTE_NAME: string = "data-old-zindex"; private static initialized: boolean; private static entries: Array; private static overlayElements: Array = []; /** * * Inicializa a classe FloatingManager. * */ private static init() { FloatingManager.entries = []; document.addEventListener('mousedown', FloatingManager.handleDocumentEvent); document.addEventListener('keydown', FloatingManager.handleKeyboardEvent); FloatingManager.initialized = true; } /** * * Retorna se o elemento clicado possui elementos internos. * */ private static innerClick(container: HTMLElement, node: HTMLElement): boolean { if (container.contains(node)) { return true; } if (container.shadowRoot && container.shadowRoot.contains(node)) { return true; } return false; } /** * * Fecha uma entrada flutuante (FloatingManager). * * @param id - Código da entrada que se deseja encerrar. * @param entry - FloatingManager. * @param target - Elemento HTML referente. * @param event - Evento específico que será verificado, como clique do mouse. * */ private static doClose(id: number, entry: FloatingEntry, target?: HTMLElement, event?:Event): void { if (!target || entry.options.autoClose) { const parent: HTMLElement | undefined = entry.parent; const content: HTMLElement | undefined = entry.content; if (parent && content) { const innerClickFunction: Function = entry.options.innerClickTest ? entry.options.innerClickTest : FloatingManager.innerClick; const backClickFunction: Function = entry.options.backClickListener ? entry.options.backClickListener : () => {}; if (!target || !innerClickFunction(content, target, event)) { content.remove(); delete FloatingManager.entries[id]; backClickFunction(); } } else { delete FloatingManager.entries[id]; } } FloatingManager.hideOverlay(); } /** * * Fecha todas as FloatingManagers abertas. * * @param event - Evento ocorrido, como clique do mouse, por exemplo. * */ private static handleDocumentEvent(event: Event): void { FloatingManager.entries.forEach((entry, index) => { FloatingManager.doClose(index, entry, event.composedPath()[0] as HTMLElement, event); }); } /** * * Captura eventos de teclado para manipular os elementos flutuantes via eventos da tecla pressionada. * * @param event - Evento de teclado. * */ private static handleKeyboardEvent(event: KeyboardEvent): void { if (event.key === 'Escape') { for (let index: number = FloatingManager.entries.length; index >= 0; index--) { const entry: FloatingEntry = FloatingManager.entries[index]; if (entry) { FloatingManager.close(index); break; } } } } /** * * Adiciona uma propriedade CSS em um elemento HTML. * * @param element - Elemento HTML que será modificado. * @param propertyName - Nome da propriedade CSS que será adicionada. * @param value - Valor da propriedade adicionada. * * */ private static applyStyle(element: HTMLElement, propertyName: string, value?: string):void { if (value) { element.style.setProperty(propertyName, value); } } /** * * Obtém o índice do FloatingManager do Elemento HMTL desejado. * * @param content - Elemento a ser buscado. * @param parent - Elemento pai do content a ser buscado. * @returns - Índice do elemento informado. */ private static getFloatIndex(content: HTMLElement, parent: HTMLElement): number { for (let index: number = 0; index < FloatingManager.entries.length; index++) { const entry: FloatingEntry = FloatingManager.entries[index]; if (entry && content === entry.content && parent === entry.parent) { return index; } } return -1; } /** * * Retorna se uma entrada flutuante existe. * * @param id - Índice para ser verificado no FloatingManager. * * @returns - Verdadeiro se existir. * */ public static isFloating(id: number): boolean{ return FloatingManager.entries[id] !== undefined; } /** * * Cria e exibe um novo item no FloatingManager. * * @param content - Elemento HTML que será criado. * @param parent - Elemento HTML que será o pai do item a ser criado. * @param options - Opções de configuração a serem adicionadas. * * @returns - ID do novo item criado. */ public static float(content: HTMLElement, parent: HTMLElement, options: FloatingOptions = { autoClose: true }): number { if (!FloatingManager.initialized) { FloatingManager.init(); } const alreadyFloatingIndex = FloatingManager.getFloatIndex(content, parent); if (alreadyFloatingIndex > -1) { return alreadyFloatingIndex; } content.style.position = options.isFixed ? 'fixed' : 'absolute'; requestAnimationFrame(()=>{ FloatingManager.applyStyle(content, "top", options.top); FloatingManager.applyStyle(content, "left", options.left); FloatingManager.applyStyle(content, "right", options.right); FloatingManager.applyStyle(content, "bottom", options.bottom); }) FloatingManager.showOverlay(options); parent.appendChild(content); FloatingManager.entries.push(new FloatingEntry(parent, content, options)); return FloatingManager.entries.length - 1; } /** * * Aplica o desfoque na página se o elemento possuir essa option ativada. * * @param options - Configurações que serão utilizadas no elemento. * */ private static showOverlay(options: FloatingOptions): void{ if(options.useOverlay){ this.createOrUpdatOverlay(options.overlayClassName).style.display = "block"; } } /** * * Desfaz o desfoque/overlay dos elementos na tela. * * */ private static hideOverlay(){ if(FloatingManager.entries.filter(entry => entry.options.useOverlay).length > 0){ return; } const overlayElement:HTMLElement|null = document.querySelector(`div#${FloatingManager.MODAL_ELEMENT_ID}`); if(overlayElement != undefined){ overlayElement.style.display = "none"; } const body = document.querySelector("body"); if(body){ body.style.overflow = ""; } } /** * * Cria ou atualiza o elemento de sobreposição. * * @param className - Classe CSS que será adicionada ao modal. * @returns - O elemento atualizado. */ private static createOrUpdatOverlay(className: string = FloatingManager.MODAL_DEFAULT_CLASSNAME):HTMLDivElement{ let overlayElement: HTMLDivElement = document.querySelector(`div#${FloatingManager.MODAL_ELEMENT_ID}`) as HTMLDivElement; const body = document.querySelector("body"); if(!overlayElement){ overlayElement = document.createElement("div"); overlayElement.id = FloatingManager.MODAL_ELEMENT_ID; if(body){ body.appendChild(overlayElement); } } if(className === FloatingManager.MODAL_DEFAULT_CLASSNAME){ FloatingManager.createStyleElement(); } else { overlayElement.classList.remove(FloatingManager.MODAL_DEFAULT_CLASSNAME); } if(body){ body.style.overflow = "hidden"; } overlayElement.className = className; return overlayElement; } /** * * Cria elemento de estilo. * Elemento que define o estilo padrão do elemento de sobreposição. */ private static createStyleElement():void{ let styleElement:HTMLStyleElement = document.querySelector(`style#${FloatingManager.STYLE_ELEMENT_ID}`) as HTMLStyleElement; if(styleElement == undefined){ styleElement = document.createElement("style"); styleElement.id = FloatingManager.STYLE_ELEMENT_ID; styleElement.appendChild(document.createTextNode(` .${FloatingManager.MODAL_DEFAULT_CLASSNAME} { position: fixed; z-index: 2; padding: 0; top: 0px; left: 0px; width: 100%; height: 100vh; box-sizing: border-box; background: rgba(0, 0, 0, 0.1); }`)); const header = document.querySelector("head"); if(header){ header.appendChild(styleElement); } } } /** * * Atualiza posição de um elemento que já está em tela. * * @param content - Elemento HTML que será atualizado. * @param parent - Elemento pai do content passado. * @param options - Novas opções desejadas. */ public static updateFloatPosition(content: HTMLElement, parent: HTMLElement, options: FloatingOptions = { autoClose: true }) { const alreadyFloatingIndex = FloatingManager.getFloatIndex(content, parent); if (alreadyFloatingIndex > -1) { FloatingManager.applyStyle(content, "top", options.top); FloatingManager.applyStyle(content, "left", options.left); FloatingManager.applyStyle(content, "right", options.right); FloatingManager.applyStyle(content, "bottom", options.bottom); } } /** * * Fecha elemento flutuante da tela. * * @param id - Índice do elemento desejado. * */ public static close(id: number) { if (FloatingManager.entries[id]) { FloatingManager.doClose(id, FloatingManager.entries[id], undefined); } } /** * Fecha todos os elemento flutuante da tela. */ public static closeAll() { if(FloatingManager.entries == undefined){ return; } FloatingManager.entries.forEach((entry, index) => { FloatingManager.doClose(index, entry, undefined); }); } /** * Controle a sobreposição do elemento * @param item - Elemento HTML que será manipulado * @returns - O ID do elemento na lista de controle */ public static subscribeOverlayControl(item: HTMLElement){ if(!item) return; const currentZIndex = this.getCSSPropertyValue(item, 'z-index') || ''; const parsedCurrZIndex = isNaN(+currentZIndex) ? 0 : +currentZIndex; item.setAttribute(FloatingManager.DATA_OLD_ZINDEX_ATTRIBUTE_NAME, String(parsedCurrZIndex)); const highestZIndex = this.getHighestZIndex(); item.style.zIndex = String(highestZIndex ? highestZIndex +1 : +parsedCurrZIndex); return this.overlayElements.push(item); } /** * * @param identifier - Elemento HTML ou ID do item que será removido do controle de sobreposição */ public static unsubscribeOverlayControl(identifier: HTMLElement | number){ if(!identifier) return; const item: HTMLElement = identifier instanceof HTMLElement ? identifier : this.overlayElements[identifier -1]; const oldZIndex = item.getAttribute(FloatingManager.DATA_OLD_ZINDEX_ATTRIBUTE_NAME) || ''; item.setAttribute(FloatingManager.DATA_OLD_ZINDEX_ATTRIBUTE_NAME, oldZIndex); this.overlayElements = this.overlayElements.filter(element => element !== item); } private static getCSSPropertyValue(element: HTMLElement, property: string){ return window.getComputedStyle(element).getPropertyValue(property); } private static getHighestZIndex(){ return this.overlayElements.reduce((acc, curr) => Math.max(acc, Number(this.getCSSPropertyValue(curr, 'z-index')) || 0), 0); } }