import { html, LitElement, css, PropertyValues } from "lit"; import { customElement, query, state, property } from "lit/decorators.js"; import HTML, { SearchableDomElement, } from "@supersoniks/concorde/core/utils/HTML"; import { Shadow, shadowable } from "../_css/shadow"; // Toast type PopPlacement = | "bottom" | "top" | "right" | "left" | "top" | "bottom center" | "top center" | "right center" | "left center"; const tagName = "sonic-pop"; @customElement(tagName) export class Pop extends LitElement { static pops = new Set(); static styles = [ css` :host { display: inline-block; vertical-align: middle; } slot[name="content"] { max-width: 100vw; background-color: var(--sc-base, #fff); position: fixed; z-index: 99999; display: block; transform: translateY(1rem) scale(0.95); opacity: 0; pointer-events: none; transition-duration: 0.15s; transition-timing-function: ease; transition-property: all; border-radius: min(calc(var(--sc-btn-rounded) * 2), 0.4em); } slot[name="content"].is-open:not(.is-empty) { transform: translateY(0) scale(1); opacity: 1; pointer-events: auto; transition-property: scale, opacity; transition-timing-function: cubic-bezier(0.25, 0.25, 0.42, 1.225); } :host([inline]) { vertical-align: baseline; } `, shadowable, ]; @state() open = false; @query("slot:not([name=content])") popBtn!: HTMLElement; @query("slot[name=content]") popContent!: HTMLElement; @property({ type: Boolean }) noToggle = false; @property({ type: Boolean, reflect: true }) inline = false; @property({ type: Boolean }) manual = false; /** * Ombre */ @property({ type: String, reflect: true }) shadow: Shadow = "lg"; @property({ type: String }) placement: PopPlacement = "bottom"; positioningRuns = false; lastContentX = 0; lastContentY = 0; resizeObserver: ResizeObserver = new ResizeObserver(() => this.computePosition(this.placement), ); @state() private triggerElement: HTMLElement | null = null; runPositioningLoop() { if (!this.positioningRuns) return; this.positioningRuns = true; this.computePosition(this.placement); window.requestAnimationFrame(() => this.runPositioningLoop()); } toggle(e: Event) { if (this.open && this.noToggle) return; const keyboardEvent = e as KeyboardEvent; if (e.type == "keydown" && (keyboardEvent.key != "ArrowDown" || this.open)) return; // Store trigger element before changing open state if (!this.open) { this.triggerElement = e.target as HTMLElement; } this.open = !this.open; this.open ? this.show() : this.hide(); } show() { this.setMaxZindex(); this.popContent?.style?.removeProperty("display"); this.open = true; this.popContent.setAttribute("tabindex", "0"); if (this.popBtn && this.popContent && !this.positioningRuns) { this.positioningRuns = true; this.lastContentX = 0; this.lastContentY = 0; this.runPositioningLoop(); } this.dispatchEvent(new CustomEvent("show")); } hide() { this.resetZindexes(); this.open = false; this.popContent.setAttribute("tabindex", "-1"); this.positioningRuns = false; if (this.triggerElement) { this.triggerElement.focus(); this.triggerElement = null; } Object.assign(this.popContent.style, { left: `${this.lastContentX}px`, top: `${this.lastContentY}px`, }); this.dispatchEvent(new CustomEvent("hide")); } /** * Remonte dans la structure html de parents en parents et si ils on une position relative ou absolute, on met leur z-index élévé */ ancestorsHavingZIndex = new Set(); setMaxZindex() { HTML.everyAncestors(this, (parent: SearchableDomElement) => { const htmlElement = parent as HTMLElement; if (!htmlElement.className) return true; if ([...htmlElement.classList].includes("@container")) { const style = htmlElement.style; style.zIndex = "999999999"; //n'appliquer l'ajout du style "position:relative" que si il n'est pas déjà présent dans le style calculé const computedStyle = getComputedStyle(htmlElement); if ( computedStyle.position !== "relative" && computedStyle.position !== "absolute" ) { style.position = "relative"; } this.ancestorsHavingZIndex.add(parent as HTMLElement); return false; } return true; }); } resetZindexes() { this.ancestorsHavingZIndex.forEach((elt) => { elt.style.removeProperty("position"); elt.style.removeProperty("z-index"); }); this.ancestorsHavingZIndex.clear(); } _handleClosePop(e: Event) { const path = e.composedPath(); const target = path[0] as HTMLElement; Pop.pops.forEach((pop: Pop) => { const popContainsTarget = path.includes(pop); const popContentContainsTarget = path.includes( pop.querySelector('[slot="content"]') as EventTarget, ); const isCloseManual = HTML.getAncestorAttributeValue(target, "data-on-select") === "keep"; if (e.type == "pointerdown" && popContainsTarget) return; if ( e.type == "click" && ((popContainsTarget && isCloseManual) || !popContentContainsTarget) ) return; pop.hide(); }); } connectedCallback(): void { super.connectedCallback(); if (Pop.pops.size == 0) { document.addEventListener("pointerdown", this._handleClosePop); document.addEventListener("click", this._handleClosePop); document.addEventListener("keydown", this._handleKeyDown); } Pop.pops.add(this); } // /* // On attends le premier rendu pour observer les changements de taille car popup content n'est pas encore défini sinon // */ protected firstUpdated(_changedProperties: PropertyValues): void { super.firstUpdated(_changedProperties); this.resizeObserver.observe(this.popContent); } disconnectedCallback(): void { if (this.popContent) { this.resizeObserver.unobserve(this.popContent); } super.disconnectedCallback(); Pop.pops.delete(this); if (Pop.pops.size == 0) { document.removeEventListener("pointerdown", this._handleClosePop); document.removeEventListener("click", this._handleClosePop); document.removeEventListener("keydown", this._handleKeyDown); } } computePosition(placement: PopPlacement) { const placementSplit = placement.split(" "); const placementBase = placementSplit[0]; let placementSec = placementSplit[1]; const padding = 5; const thisRect = this.getBoundingClientRect(); const scrollableAncestor = HTML.getScrollableAncestor( this.popContent, ) as HTMLElement; const scrollableAncestorRect = scrollableAncestor?.getBoundingClientRect(); const minX = Math.max(0, scrollableAncestorRect?.left || 0) + padding; const minY = Math.max(0, scrollableAncestorRect?.top || 0) + padding; const maxX = Math.min( window.innerWidth, scrollableAncestorRect?.right || window.innerWidth, ) - padding; const maxY = Math.min( window.innerHeight, scrollableAncestorRect?.bottom || window.innerHeight, ) - padding; const x0 = thisRect.left; const y0 = thisRect.top; let x = x0, y = y0; let contentRect = this.popContent?.getBoundingClientRect(); const yTop = y0 - contentRect.height; const xLeft = x0 - contentRect.width; const xRight = x0 + thisRect.width; const yBottom = y0 + thisRect.height; const xCenter = x0 + (thisRect.width - contentRect.width) * 0.5; const yCenter = y0 + (thisRect.height - contentRect.height) * 0.5; switch (placementBase) { case "bottom": y = yBottom; if (placementSec == "center") { x = xCenter; } break; case "top": y = yTop; if (placementSec == "center") { x = xCenter; } break; case "left": x = xLeft; if (placementSec == "center") { y = yCenter; } break; case "right": x = xRight; if (placementSec == "center") { y = yCenter; } break; } this.lastContentX += x - contentRect.x; this.lastContentY += y - contentRect.y; Object.assign(this.popContent.style, { left: `${this.lastContentX}px`, top: `${this.lastContentY}px`, }); contentRect = this.popContent?.getBoundingClientRect(); if (contentRect.x < minX && placementBase == "left") x = xRight; if (contentRect.y < minY && placementBase == "top") y = yBottom; if (contentRect.x + contentRect.width > maxX && placementBase == "right") x = xLeft; if (contentRect.y + contentRect.height > maxY && placementBase == "bottom") y = yTop; this.lastContentX += x - contentRect.x; this.lastContentY += y - contentRect.y; Object.assign(this.popContent.style, { left: `${this.lastContentX}px`, top: `${this.lastContentY}px`, }); contentRect = this.popContent?.getBoundingClientRect(); if (contentRect.x < minX) { this.lastContentX += minX - contentRect.x; } if (contentRect.y < minY) { this.lastContentY += minY - contentRect.y; } Object.assign(this.popContent.style, { left: `${this.lastContentX}px`, top: `${this.lastContentY}px`, }); contentRect = this.popContent?.getBoundingClientRect(); if (contentRect.x + contentRect.width > maxX) { this.lastContentX += maxX - (contentRect.x + contentRect.width); } if (contentRect.y + contentRect.height > maxY) { this.lastContentY += maxY - (contentRect.y + contentRect.height); } Object.assign(this.popContent.style, { left: `${this.lastContentX}px`, top: `${this.lastContentY}px`, }); } private _handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape" && this.open) { e.stopPropagation(); this.hide(); } }; render() { return html` {} : this.toggle} @keydown=${this.manual ? () => {} : this.toggle} class="contents" > `; } }