import { LightningElement, api } from "lwc"; import { LightningSlotElement, PopoverRequestCloseType, PopperPlacement } from "typings/custom"; import { autoUpdate, computePosition, flip, size, shift, arrow, platform } from "@floating-ui/dom"; import { offsetParent } from "composed-offset-position"; import cx from "classnames"; import debounce from "debounce"; import { isPrerender } from "dxUtils/seo"; const isEventOutsideElements = ( e: Event, ...els: (HTMLElement | null)[] ): boolean => !e .composedPath() .some( (el) => (el as HTMLElement).isSameNode && els.some((_el) => (el as HTMLElement).isSameNode(_el)) ); export default class Popover extends LightningElement { private autoUpdateCleanup?: () => void; @api offset?: "small" | "medium"; @api pagePadding?: number = 16; // padding between dropdown and the edge of the page @api placement?: PopperPlacement = "bottom-start"; @api small?: boolean = false; @api openOnHover?: boolean = false; // dropdown opens/closes with hover @api width?: string | null = null; @api fullWidth?: boolean | null; @api showArrow?: boolean | null; @api get open() { return this._open; } set open(value: boolean | null) { this.controlled = typeof value === "boolean"; if (value !== this._open) { if (value) { this.openPopover(); } else { this.closePopover(); } } } // public api @api dispatchRequestCloseEvent = (type: PopoverRequestCloseType) => { this.dispatchEvent( new CustomEvent("requestclose", { detail: type, bubbles: true, composed: true }) ); }; @api openPopover() { this._rendered = true; if (this.control && !this.control.disabled) { this._open = true; this.control.setAttribute("aria-expanded", "true"); if (this.popover) { this.autoUpdateCleanup = autoUpdate(this.control, this.popover, this.setPosition); } this.dispatchEvent(new CustomEvent("open")); setTimeout(() => { this.setPosition(); this.attachListenersTopopover(); }); } } @api closePopover(focusControl: boolean = false) { this._open = false; this.autoUpdateCleanup?.(); if (focusControl && this.control && this.control.focus) { this.control.focus(); } if (this.control) { this.control.setAttribute("aria-expanded", "false"); } document.removeEventListener("click", this.interactOutsideListener); document.removeEventListener("focusout", this.interactOutsideListener); if (this.openOnHover) { document.removeEventListener( "mousemove", this.interactOutsideListener ); } this.dispatchEvent(new CustomEvent("close")); } // PRIVATE VARS private controlled: boolean = false; private _open: boolean = false; private _rendered: boolean = isPrerender(); private _role: string | null = null; private arrow: HTMLElement | null = null; private control: (HTMLElement & { disabled?: boolean }) | null = null; private focusedValue: string | null = null; private popover: HTMLElement | null = null; private popoverContent: HTMLElement | null = null; // LIFECYCLE METHODS disconnectedCallback() { document.removeEventListener("click", this.interactOutsideListener); document.removeEventListener("mousemove", this.interactOutsideListener); document.removeEventListener("focusout", this.interactOutsideListener); this.removeControlListeners(); } renderedCallback() { if (!this.popover) { this.popover = this.template.querySelector(".popover-container"); } if (!this.popoverContent) { this.popoverContent = this.template.querySelector(".popover"); } if (!this.arrow && this.showArrow) { this.arrow = this.template.querySelector(".popover-arrow"); } } // GETTERS private get className() { return cx( "popover", this.offset && `popover-offset-${this.offset}`, this.small && "popover-small", this.width && "popover-overridewidth", this.fullWidth && "popover-fullwidth" ); } private get containerClassName() { return cx("popover-container", this.open && "popover-container_open"); } private get style() { if (this.fullWidth && this.width) { console.error( 'The "width" and "full-width" properties cannot be provided simultaneously.' ); } return cx(this.width && `width: ${this.width};`); } // CLASS FNS private addControlListeners() { if (this.control) { this.control.addEventListener("keydown", this.onControlKeydown); if (!this.openOnHover) { this.control.addEventListener("click", this.onControlClick); } if (this.openOnHover) { this.control.addEventListener( "mouseenter", this.onControlMouseEnter ); } } } private removeControlListeners() { if (this.control) { this.control.removeEventListener("keydown", this.onControlKeydown); this.control.removeEventListener("click", this.onControlClick); this.control.removeEventListener( "mouseenter", this.onControlMouseEnter ); } } private _interactOutsideListener = (e: Event) => { if ( this.open && this.popover && isEventOutsideElements(e, this.control, this.popover) ) { this.closePopover(); this.dispatchRequestCloseEvent("interact-outside"); } }; private interactOutsideListener = (e: Event) => // @ts-ignore something weird happening here with debounce types debounce(this._interactOutsideListener(e), 100); private onControlMouseEnter = () => this.openPopover(); private onControlKeydown = (e: KeyboardEvent) => { switch (e.key) { case "ArrowDown": { e.preventDefault(); if (!this.open) { this.openPopover(); } this.dispatchEvent(new CustomEvent("controlarrowdown")); break; } default: } }; private onControlClick = () => { if (this.controlled && this.open) { this.dispatchRequestCloseEvent("click-control"); return; } if (this.controlled && !this.open) { this.dispatchEvent( new CustomEvent("requestopen", { bubbles: true, composed: true }) ); return; } if (this.open) { this.closePopover(); return; } this.openPopover(); }; private onSlotChange(e: Event) { const slot = e.target as LightningSlotElement; const elements = slot.assignedElements(); const slotted = elements.length === 0 ? null : elements[0]; // allows dropdown/select to compose popover const slotElement = ( slotted?.tagName === "SLOT" ? slotted.firstChild : slotted ) as HTMLElement | null; const isWorkToDo = slotElement && (!this.control || !slotElement.isSameNode(this.control)); if (!isWorkToDo) { return; } slotElement.setAttribute("aria-haspopup", "true"); slotElement.style.cursor = this.openOnHover ? "default" : "cursor"; this.control = slotElement; this.addControlListeners(); this.setPosition(); } private onKeyDown(e: KeyboardEvent): void { switch (e.key) { case "Esc": case "Escape": this.dispatchRequestCloseEvent("keypress-escape"); this.closePopover(true); break; default: } } private attachListenersTopopover() { document.addEventListener("click", this.interactOutsideListener); document.addEventListener("focusout", this.interactOutsideListener); if (this.openOnHover) { document.addEventListener( "mousemove", this.interactOutsideListener ); } } private _setPosition = async () => { if (this.popover && this.control) { await Promise.resolve(); const popoverEl = this.popover; const middleware = [flip(), shift({ padding: this.pagePadding })]; if (this.fullWidth) { middleware.unshift( size({ apply({ rects }) { Object.assign(popoverEl.style, { width: `${rects.reference.width}px` }); } }) ); } if (this.arrow) { middleware.push(arrow({ element: this.arrow, padding: 24 })); } computePosition(this.control, popoverEl, { placement: this.placement, platform: { ...platform, getOffsetParent: (element) => platform.getOffsetParent(element, offsetParent) }, middleware }).then(({ x, y, placement, middlewareData }) => { Object.assign(popoverEl.style, { left: `${x}px`, top: `${y}px` }); if (this.arrow && middlewareData.arrow) { const { x: arrowX, y: arrowY } = middlewareData.arrow; const popoverPlacementSide = placement.split("-")[0] as | "top" | "bottom" | "left" | "right"; const staticSide = { top: "bottom", right: "left", bottom: "top", left: "right" }[popoverPlacementSide]; const arrowStyles = { left: arrowX != null ? `${arrowX}px` : "", top: arrowY != null ? `${arrowY}px` : "", right: "", bottom: "", [staticSide]: "-4px" }; if (this.offset && this.popoverContent) { arrowStyles.marginTop = getComputedStyle( this.popoverContent ).marginTop; } Object.assign(this.arrow.style, arrowStyles); } }); } }; public get setPosition() { return this._setPosition; } public set setPosition(value) { this._setPosition = value; } }