import { html, LitElement, PropertyValueMap, unsafeCSS } from "lit";
import { property, state } from "lit/decorators.js";
import { FRoot } from "../../mixins/components/f-root/f-root";
import globalStyle from "./f-popover-global.scss?inline";
import {
computePosition,
autoPlacement,
offset,
shift,
flip,
autoUpdate,
Placement
} from "@floating-ui/dom";
import { flowElement } from "./../../utils";
import { injectCss } from "@cldcvr/flow-core-config";
injectCss("f-popover", globalStyle);
export type FPopoverState =
| "subtle"
| "default"
| "secondary"
| "success"
| "warning"
| "danger"
| "primary"
| "transparent";
// export type FPopoverVariant = "relative" | "absolute";
export type FPopoverPlacement =
| "top"
| "top-start"
| "top-end"
| "right"
| "right-start"
| "right-end"
| "bottom"
| "bottom-start"
| "bottom-end"
| "left"
| "left-start"
| "left-end"
| "auto";
export type FPopoverSize =
| "stretch"
| "large"
| "medium"
| "small"
| "hug-content"
| FPopoverCustomSize;
export type FPopoverCustomWidth =
| `${number}px`
| `${number}%`
| `${number}vw`
| `auto`
| `fit-content`;
export type FPopoverCustomHeight =
| `${number}px`
| `${number}%`
| `${number}vh`
| `auto`
| `fit-content`;
export type FPopoverCustomSize = `custom(${FPopoverCustomWidth},${FPopoverCustomHeight})`;
export type FPopOverOffset = {
mainAxis: number;
crossAxis?: number;
alignmentAxis?: number;
};
@flowElement("f-popover")
export class FPopover extends FRoot {
/**
* css loaded from scss file
*/
static styles = [unsafeCSS(globalStyle)];
// /**
// * @attribute variant defines the position of a popover. A popover can be either relative to the source or absolute to the viewport.
// */
// @property({ type: String, reflect: true })
// variant?: FPopoverVariant = "relative";
/**
* @attribute In any placement, by default the popover is centrally aligned to the source and moves according to the space available.
*/
@property({ type: String, reflect: true })
placement?: FPopoverPlacement = "auto";
/**
* @attribute A popover can have different sizes depending on the use case from the range: small, medium, large, stretch.
*/
@property({ type: String, reflect: true })
size?: FPopoverSize = "medium";
/**
* @attribute is it open?
*/
@property({ type: Boolean, reflect: true })
open?: boolean = false;
/**
* @attribute display overlay?
*/
@property({ type: Boolean, reflect: true })
overlay?: boolean = true;
/**
* @attribute display box-shadow
*/
@property({ type: Boolean, reflect: true })
shadow?: boolean = false;
/**
* @attribute stretch the height to auto
*/
@property({ type: Boolean, reflect: true, attribute: "auto-height" })
autoHeight?: boolean = false;
/**
* @attribute conditional closing popover on escape key press
*/
@property({ type: Boolean, reflect: true, attribute: "close-on-escape" })
closeOnEscape?: boolean = true;
/**
* @attribute state property defines the background color of a f-div. It can take only surface colors defined in the library.
*/
@property({ reflect: true, type: String })
state?: FPopoverState = "default";
/**
* @attribute query selector of target
*/
@property({ type: [String, Object], reflect: true })
target!: string | HTMLElement;
@state()
cleanup!: () => void;
@state()
isEscapeClicked = false;
escapeHandler = (e: KeyboardEvent) => this.escapekeyHandle(e, this);
isTooltip = false;
overlayElement?: HTMLDivElement;
reqAniFrame?: number;
offset: FPopOverOffset | null = null;
get targetElement() {
if (typeof this.target === "string") {
return document.querySelector(this.target);
} else {
return this.target;
}
}
computePlacement() {
return this.placement === "auto" ? undefined : this.placement;
}
computePosition(isTooltip: boolean) {
let target = document.body;
if (this.targetElement && this.open) {
if (!isTooltip) {
this.targetElement.style.zIndex = "201";
}
target = this.targetElement;
}
if (this.open) {
if (this.size && this.size?.startsWith("custom")) {
const regex = /custom\((.*?)\)/i;
const matches = this.size.match(regex);
if (matches) {
const [width, height] = matches[1].split(",");
this.classList.add("f-popover-custom-size");
this.style.setProperty("--custom-width", width);
this.style.setProperty("--custom-height", height);
}
}
if (this.cleanup) {
this.cleanup();
}
this.cleanup = autoUpdate(target, this, () => {
computePosition(target, this, {
placement: this.computePlacement(),
strategy: "fixed",
middleware: [
offset(
this.offset ?? {
mainAxis: isTooltip ? 8 : 12,
crossAxis: isTooltip ? 1 : 12,
alignmentAxis: 12
}
),
this.placement === "auto"
? autoPlacement({ boundary: document.body })
: flip({
fallbackPlacements: [
this.placement as Placement,
"bottom-start",
"bottom-end",
"top-start",
"top-end"
],
crossAxis: false,
boundary: document.body
}),
shift({ padding: isTooltip ? 0 : 16 })
]
})
.then(({ x, y }) => {
if (x < 0) {
target.style.removeProperty("z-index");
x = 0;
}
if (y < 0) {
target.style.removeProperty("z-index");
y = 0;
}
if (target.nodeName.toLowerCase() !== "body") {
Object.assign(this.style, {
top: `${y}px`,
left: `${x}px`
});
} else {
Object.assign(this.style, this.getBodyTargetPlacementCords());
}
this.querySelectorAll("f-popover").forEach(pop => {
(pop as LitElement).requestUpdate();
});
})
.catch(_err => {});
});
}
}
getBodyTargetPlacementCords() {
if (this.placement?.includes("top")) {
let left: number | undefined = (document.body.offsetWidth - this.offsetWidth) / 2;
const top = 0;
let right = undefined;
if (this.placement?.includes("start")) {
left = 0;
right = undefined;
}
if (this.placement?.includes("end")) {
right = 0;
left = undefined;
}
return {
top: `${top}px`,
left: left ? `${left}px` : left,
right: right ? `${right}px` : right
};
} else if (this.placement?.includes("bottom")) {
let left: number | undefined = (document.body.offsetWidth - this.offsetWidth) / 2;
const bottom = 0;
let right = undefined;
if (this.placement?.includes("start")) {
left = 0;
right = undefined;
}
if (this.placement?.includes("end")) {
right = 0;
left = undefined;
}
return {
bottom: `${bottom}px`,
left: left ? `${left}px` : left,
right: right ? `${right}px` : right
};
} else if (this.placement?.includes("right")) {
let bottom: number | undefined = undefined;
let top: number | undefined = (document.body.offsetHeight - this.offsetHeight) / 2;
const right = 0;
if (this.placement?.includes("start")) {
top = 0;
bottom = undefined;
}
if (this.placement?.includes("end")) {
bottom = 0;
top = undefined;
}
return {
right: `${right}px`,
bottom: bottom ? `${bottom}px` : bottom,
top: top ? `${top}px` : top
};
} else if (this.placement?.includes("left")) {
let bottom: number | undefined = undefined;
let top: number | undefined = (document.body.offsetHeight - this.offsetHeight) / 2;
const left = 0;
if (this.placement?.includes("start")) {
top = 0;
bottom = undefined;
}
if (this.placement?.includes("end")) {
bottom = 0;
top = undefined;
}
return {
left: `${left}px`,
bottom: bottom ? `${bottom}px` : bottom,
top: top ? `${top}px` : top
};
} else {
const left = (document.body.offsetWidth - this.offsetWidth) / 2;
const top = (document.body.offsetHeight - this.offsetHeight) / 2;
return {
top: `${top}px`,
left: `${left}px`
};
}
}
disconnectedCallback() {
if (this.cleanup) {
this.cleanup();
}
document.removeEventListener("keydown", this.escapeHandler);
this.removeEventListener("click", this.dispatchEsc);
super.disconnectedCallback();
// clear request animation frame if any
if (this.reqAniFrame) {
cancelAnimationFrame(this.reqAniFrame);
}
if (this.targetElement) {
this.targetElement.style.removeProperty("z-index");
}
if (this.overlayElement) {
this.overlayElement.remove();
this.overlayElement = undefined;
}
}
connectedCallback() {
super.connectedCallback();
document.addEventListener("keydown", this.escapeHandler);
this.addEventListener("click", this.dispatchEsc);
}
dispatchEsc() {
if (this.isEscapeClicked && this.closeOnEscape) {
const event = new CustomEvent("esc", {
detail: {
message: "Popover close on escape key"
},
bubbles: true,
composed: true
});
this.isEscapeClicked = false;
this.open = false;
this.dispatchEvent(event);
}
}
escapekeyHandle(e: KeyboardEvent, el: HTMLElement) {
if (e.key === "Escape") {
this.isEscapeClicked = true;
el.click();
}
}
overlayClick() {
const event = new CustomEvent("overlay-click", {
detail: {
message: "Popover overlay clicked"
},
bubbles: true,
composed: true
});
this.dispatchEvent(event);
}
render() {
this.classList.forEach(cl => {
if (cl === "tooltip") {
this.isTooltip = true;
} else {
this.isTooltip = false;
}
});
if (this.open) {
if (!this.isTooltip) {
if (!this.overlayElement) {
this.overlayElement = document.createElement("div");
this.overlayElement.classList.add("f-overlay");
this.overlayElement.dataset.qaOverlay = "true";
document.body.append(this.overlayElement);
this.overlayElement.onclick = () => {
this.overlayClick();
};
}
if (!this.overlay) {
this.overlayElement.dataset.transparent = "true";
} else {
delete this.overlayElement.dataset.transparent;
}
}
this.computePosition(this.isTooltip);
return html``;
} else {
if (this.targetElement) {
this.targetElement.style.removeProperty("z-index");
}
if (this.overlayElement) {
this.overlayElement.remove();
this.overlayElement = undefined;
}
if (this.size && this.size?.includes("custom")) {
this.classList.remove("f-popover-custom-size");
this.style.setProperty("--custom-width", null);
this.style.setProperty("--custom-height", null);
}
}
return ``;
}
protected updated(changedProperties: PropertyValueMap | Map): void {
super.updated(changedProperties);
/**
* method that is executed before every repaint
*/
// clear request animation frame if any
if (this.reqAniFrame) {
cancelAnimationFrame(this.reqAniFrame);
}
this.reqAniFrame = requestAnimationFrame(() => {
if (!this.size?.includes("custom")) {
if (this.autoHeight && this.open) {
const topPosition = Number(this.style.top.replace("px", "")) + 16;
this.style.height = `calc(100vh - ${topPosition}px)`;
this.style.maxHeight = `calc(100vh - ${topPosition}px)`;
} else if (changedProperties.has("autoHeight") && !this.autoHeight) {
this.style.removeProperty("height");
this.style.removeProperty("max-height");
}
}
});
}
}