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"
>
`;
}
}