diff --git a/src/modules/esl-popup/core/esl-popup.ts b/src/modules/esl-popup/core/esl-popup.ts index 397c9d210..e40801f03 100644 --- a/src/modules/esl-popup/core/esl-popup.ts +++ b/src/modules/esl-popup/core/esl-popup.ts @@ -1,9 +1,12 @@ import {ExportNs} from '../../esl-utils/environment/export-ns'; import {attr, jsonAttr} from '../../esl-base-element/core'; +import {bind} from '../../esl-utils/decorators/bind'; import {prop} from '../../esl-utils/decorators/prop'; import {rafDecorator} from '../../esl-utils/async/raf'; import {ESLToggleable} from '../../esl-toggleable/core'; +import {listScrollParents} from './listScrollParents'; + import type {ToggleableActionParams} from '../../esl-toggleable/core'; export interface PopupActionParams extends ToggleableActionParams { @@ -17,6 +20,11 @@ export interface PopupActionParams extends ToggleableActionParams { offsetWindow?: number; } +export interface ActivatorObserver { + unsubscribers?: (() => void)[]; + observer?: IntersectionObserver; +} + @ExportNs('Popup') export class ESLPopup extends ESLToggleable { public static is = 'esl-popup'; @@ -27,6 +35,12 @@ export class ESLPopup extends ESLToggleable { protected _offsetWindow: number; protected _deferredUpdatePosition = rafDecorator(() => this._updatePosition()); + protected _left: number; + protected _top: number; + protected _leftT: number; + protected _topT: number; + protected _activatorObserver: ActivatorObserver; + @attr({defaultValue: 'top'}) public position: string; @attr({defaultValue: 'fit'}) public behavior: string; @@ -41,19 +55,23 @@ export class ESLPopup extends ESLToggleable { @prop() public closeOnOutsideAction = true; public connectedCallback() { - this.$arrow = this.querySelector('.esl-popup-arrow'); - super.connectedCallback(); + + setTimeout(() => { + this.$arrow = this.querySelector('span.esl-popup-arrow'); + }); } protected bindEvents() { super.bindEvents(); window.addEventListener('resize', this._deferredUpdatePosition); + window.addEventListener('scroll', this._deferredUpdatePosition); } protected unbindEvents() { super.unbindEvents(); window.removeEventListener('resize', this._deferredUpdatePosition); + window.removeEventListener('scroll', this._deferredUpdatePosition); } // TODO: move to utilities @@ -61,6 +79,10 @@ export class ESLPopup extends ESLToggleable { return document.documentElement.clientWidth || document.body.clientWidth; } + protected get _windowBottom() { + return window.pageYOffset + window.innerHeight; + } + public onShow(params: PopupActionParams) { super.onShow(params); @@ -74,6 +96,57 @@ export class ESLPopup extends ESLToggleable { this._offsetWindow = params.offsetWindow || 0; this._updatePosition(); + this.activator && this._addActivatorObserver(this.activator); + } + + public onHide(params: PopupActionParams) { + super.onHide(params); + + this.activator && this._removeActivatorObserver(this.activator); + } + + @bind + protected onActivatorIntersection(entries: IntersectionObserverEntry[], observer: IntersectionObserver) { + if (!entries[0].isIntersecting) { + this.hide(); + } + } + + @bind + protected onActivatorScroll(e: Event) { + this._updatePosition(); + } + + protected _addActivatorObserver(target: HTMLElement) { + const scrollParents = listScrollParents(target); + + const unsubscribers = scrollParents.map(($root) => { + const options = {passive: true} as EventListenerOptions; + $root.addEventListener('scroll', this.onActivatorScroll, options); + return () => { + $root && $root.removeEventListener('scroll', this.onActivatorScroll, options); + }; + }); + + const options = { + rootMargin: `-${this._offsetWindow}px`, + threshold: 1.0 + } as IntersectionObserverInit; + + const observer = new IntersectionObserver(this.onActivatorIntersection, options); + observer.observe(target); + + this._activatorObserver = { + unsubscribers, + observer + }; + } + + protected _removeActivatorObserver(target: HTMLElement) { + this._activatorObserver.observer?.disconnect(); + this._activatorObserver.observer = undefined; + this._activatorObserver.unsubscribers?.forEach((cb) => cb()); + this._activatorObserver.unsubscribers = []; } protected set _arrowPosition(value: string) { @@ -93,25 +166,76 @@ export class ESLPopup extends ESLToggleable { // set arrow position if (this.$arrow) { - this.$arrow.style.left = `${arrowLeft}px`; + this.$arrow.style.left = ['top', 'bottom'].includes(position) ? `${arrowLeft}px` : 'none'; + this.$arrow.style.top = ['left', 'right'].includes(position) ? `${arrowTop}px` : 'none'; this._arrowPosition = position; } } protected _calculatePosition($activator: HTMLElement) { - const {left, arrowLeft} = this._calculateLeft($activator); - const {top, arrowTop, position} = this._calculateTop($activator); + if (this.position === 'top') { + const {left, arrowLeft} = this._calculateLeftT($activator); + const {top, arrowTop, position} = this._calculateTopT($activator); + + return { + left, + top, + arrowLeft, + arrowTop, + position + }; + } + + if (this.position === 'bottom') { + const {left, arrowLeft} = this._calculateLeftT($activator); + const {top, arrowTop, position} = this._calculateTopB($activator); + + return { + left, + top, + arrowLeft, + arrowTop, + position + }; + } + + if (this.position === 'left') { + const {left, arrowLeft, position} = this._calculateLeftL($activator); + const {top, arrowTop} = this._calculateTopH($activator); + + return { + left, + top, + arrowLeft, + arrowTop, + position + }; + } + + if (this.position === 'right') { + const {left, arrowLeft, position} = this._calculateLeftR($activator); + const {top, arrowTop} = this._calculateTopH($activator); + + return { + left, + top, + arrowLeft, + arrowTop, + position + }; + } return { - left, - top, - arrowLeft, - arrowTop, - position + left: 0, + top: 0, + arrowLeft: 0, + arrowTop: 0, + position: 'top' }; + } - protected _calculateLeft($activator: HTMLElement) { + protected _calculateLeftT($activator: HTMLElement) { const triggerRect = $activator.getBoundingClientRect(); const triggerPosX = triggerRect.left + window.pageXOffset; const centerX = triggerPosX + triggerRect.width / 2; @@ -137,7 +261,7 @@ export class ESLPopup extends ESLToggleable { }; } - protected _calculateTop($activator: HTMLElement) { + protected _calculateTopT($activator: HTMLElement) { const arrowRect = this.$arrow ? this.$arrow.getBoundingClientRect() : new DOMRect(); const triggerRect = $activator.getBoundingClientRect(); const triggerPosY = triggerRect.top + window.pageYOffset; @@ -158,4 +282,99 @@ export class ESLPopup extends ESLToggleable { position }; } + + protected _calculateTopB($activator: HTMLElement) { + const arrowRect = this.$arrow ? this.$arrow.getBoundingClientRect() : new DOMRect(); + const triggerRect = $activator.getBoundingClientRect(); + const triggerPosY = triggerRect.top + window.pageYOffset; + const arrowHeight = arrowRect.height / 2; + + let arrowTop = triggerPosY + triggerRect.height + this._offsetTrigger; + let top = arrowTop + arrowHeight + this._offsetTrigger; + const bottom = top + this.offsetHeight; + let position = 'bottom'; + if (this.behavior === 'fit' && this._windowBottom < bottom) { + arrowTop = triggerPosY - this._offsetTrigger - arrowHeight; + top = arrowTop - this.offsetHeight; + position = 'top'; + } + + return { + top, + arrowTop, + position + }; + } + + protected _calculateLeftL($activator: HTMLElement) { + const arrowRect = this.$arrow ? this.$arrow.getBoundingClientRect() : new DOMRect(); + const triggerRect = $activator.getBoundingClientRect(); + const triggerLeft = triggerRect.left + window.pageXOffset; + + let arrowLeft = triggerLeft - this._offsetWindow - arrowRect.width / 2; + let left = triggerLeft - this._offsetTrigger - arrowRect.width / 2 - this.offsetWidth; + let position = 'left'; + + if (this.behavior === 'fit' && left < this._offsetWindow) { + const triggerRight = triggerRect.right + window.pageXOffset; + left = triggerRight + this._offsetTrigger + arrowRect.width / 2; + arrowLeft = triggerRight + this._offsetTrigger; + position = 'right'; + } + + return { + left, + arrowLeft, + position + }; + } + + protected _calculateTopH($activator: HTMLElement) { + const arrowRect = this.$arrow ? this.$arrow.getBoundingClientRect() : new DOMRect(); + const triggerRect = $activator.getBoundingClientRect(); + const triggerTop = triggerRect.top + window.pageYOffset; + const triggerCenterY = triggerTop + triggerRect.height / 2; + const arrowHeight = arrowRect.height / 2; + + let top = triggerCenterY - this.offsetHeight / 2; + let arrowAdjust = 0; + if (this.behavior === 'fit' && (top - this._offsetWindow) < window.pageYOffset) { + arrowAdjust += window.pageYOffset - (top - this._offsetWindow); + top = this._offsetWindow + window.pageYOffset; + } + if (this.behavior === 'fit' && (top + this.offsetHeight + this._offsetWindow) > this._windowBottom) { + arrowAdjust += this._windowBottom - (this.offsetHeight + this._offsetWindow) - top; + top = this._windowBottom - (this.offsetHeight + this._offsetWindow); + } + + const arrowTop = this.offsetHeight / 2 - arrowHeight - arrowAdjust; + + return { + top, + arrowTop + }; + } + + protected _calculateLeftR($activator: HTMLElement) { + const arrowRect = this.$arrow ? this.$arrow.getBoundingClientRect() : new DOMRect(); + const triggerRect = $activator.getBoundingClientRect(); + const triggerRight = triggerRect.right + window.pageXOffset; + + let left = triggerRight + this._offsetTrigger + arrowRect.width / 2; + let arrowLeft = triggerRight + this._offsetTrigger; + let position = 'right'; + + if (this.behavior === 'fit' && (left + this._offsetWindow + this.offsetWidth) > this._windowWidth) { + const triggerLeft = triggerRect.left + window.pageXOffset; + left = triggerLeft - this._offsetTrigger - arrowRect.width / 2 - this.offsetWidth; + arrowLeft = triggerLeft - this._offsetWindow - arrowRect.width / 2; + position = 'left'; + } + + return { + left, + arrowLeft, + position + }; + } } diff --git a/src/modules/esl-popup/core/getComputedStyle.ts b/src/modules/esl-popup/core/getComputedStyle.ts new file mode 100644 index 000000000..2aad00f6f --- /dev/null +++ b/src/modules/esl-popup/core/getComputedStyle.ts @@ -0,0 +1,5 @@ +import {getWindow} from './getWindow'; + +export function getComputedStyle(element: Element): CSSStyleDeclaration { + return getWindow(element).getComputedStyle(element); +} diff --git a/src/modules/esl-popup/core/getDocumentElement.ts b/src/modules/esl-popup/core/getDocumentElement.ts new file mode 100644 index 000000000..6746fcb55 --- /dev/null +++ b/src/modules/esl-popup/core/getDocumentElement.ts @@ -0,0 +1,5 @@ +export function getDocumentElement(element: Element | Window): Element { + return ((element instanceof Window + ? element.document + : element.ownerDocument) || window.document).documentElement; +} diff --git a/src/modules/esl-popup/core/getNodeName.ts b/src/modules/esl-popup/core/getNodeName.ts new file mode 100644 index 000000000..99162d43f --- /dev/null +++ b/src/modules/esl-popup/core/getNodeName.ts @@ -0,0 +1,3 @@ +export function getNodeName(element?: Node | Window): string { + return element && !(element instanceof Window)? (element.nodeName).toLowerCase() : ''; +} diff --git a/src/modules/esl-popup/core/getParentNode.ts b/src/modules/esl-popup/core/getParentNode.ts new file mode 100644 index 000000000..36ae9c765 --- /dev/null +++ b/src/modules/esl-popup/core/getParentNode.ts @@ -0,0 +1,12 @@ +import {getNodeName} from './getNodeName'; +import {getDocumentElement} from './getDocumentElement'; + +export function getParentNode(element: Element | ShadowRoot): Node { + if (getNodeName(element) === 'html') { + return element; + } + + return (element instanceof ShadowRoot + ? element.host + : element.assignedSlot || element.parentNode) || getDocumentElement(element as Element); +} diff --git a/src/modules/esl-popup/core/getScrollParent.ts b/src/modules/esl-popup/core/getScrollParent.ts new file mode 100644 index 000000000..13da21461 --- /dev/null +++ b/src/modules/esl-popup/core/getScrollParent.ts @@ -0,0 +1,15 @@ +import {getNodeName} from './getNodeName'; +import {isScrollParent} from './isScrollParent'; +import {getParentNode} from './getParentNode'; + +export function getScrollParent(node: Node): Element { + if (['html', 'body', '#document'].indexOf(getNodeName(node)) >= 0) { + return node.ownerDocument?.body as Element; + } + + if (node instanceof HTMLElement && isScrollParent(node as Element)) { + return node as Element; + } + + return getScrollParent(getParentNode(node as Element)); +} diff --git a/src/modules/esl-popup/core/getWindow.ts b/src/modules/esl-popup/core/getWindow.ts new file mode 100644 index 000000000..6ddaaa085 --- /dev/null +++ b/src/modules/esl-popup/core/getWindow.ts @@ -0,0 +1,12 @@ +export function getWindow(node: Node | Window): Window { + if (node == null) { + return window; + } + + if (node instanceof Window) { + return node; + } + + const ownerDocument = node.ownerDocument; + return ownerDocument ? ownerDocument.defaultView || window : window; +} diff --git a/src/modules/esl-popup/core/isScrollParent.ts b/src/modules/esl-popup/core/isScrollParent.ts new file mode 100644 index 000000000..7b5514e19 --- /dev/null +++ b/src/modules/esl-popup/core/isScrollParent.ts @@ -0,0 +1,7 @@ +import {getComputedStyle} from './getComputedStyle'; + +export function isScrollParent(element: Element): boolean { + // Firefox wants us to check `-x` and `-y` variations as well + const {overflow, overflowX, overflowY} = getComputedStyle(element); + return /auto|scroll|overlay|hidden/.test(overflow + overflowY + overflowX); +} diff --git a/src/modules/esl-popup/core/listScrollParents.ts b/src/modules/esl-popup/core/listScrollParents.ts new file mode 100644 index 000000000..256929b0f --- /dev/null +++ b/src/modules/esl-popup/core/listScrollParents.ts @@ -0,0 +1,22 @@ +import {getScrollParent} from './getScrollParent'; +import {getParentNode} from './getParentNode'; +import {isScrollParent} from './isScrollParent'; + +/* +given a DOM element, return the list of all scroll parents, up the list of ancesors +until we get to the top window object. This list is what we attach scroll listeners +to, because if any of these parent elements scroll, we'll need to re-calculate the +reference element's position. +*/ +export function listScrollParents(element: Node, list: Element[] = []): Element[] { + const scrollParent = getScrollParent(element); + const isBody = scrollParent === element.ownerDocument?.body; + const target = isBody + ? isScrollParent(scrollParent) ? scrollParent : [] + : scrollParent; + + const updatedList = list.concat(target); + return isBody + ? updatedList + : updatedList.concat(listScrollParents(getParentNode(scrollParent))); +}