Skip to content

Commit

Permalink
feat(esl-popup): add bottom, left, right position and update position…
Browse files Browse the repository at this point in the history
… when parents scroll
  • Loading branch information
dshovchko committed Jul 21, 2021
1 parent b0490df commit 9242fca
Show file tree
Hide file tree
Showing 9 changed files with 312 additions and 12 deletions.
243 changes: 231 additions & 12 deletions src/modules/esl-popup/core/esl-popup.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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';
Expand All @@ -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;

Expand All @@ -41,26 +55,34 @@ 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
protected get _windowWidth() {
return document.documentElement.clientWidth || document.body.clientWidth;
}

protected get _windowBottom() {
return window.pageYOffset + window.innerHeight;
}

public onShow(params: PopupActionParams) {
super.onShow(params);

Expand All @@ -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) {
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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
};
}
}
5 changes: 5 additions & 0 deletions src/modules/esl-popup/core/getComputedStyle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import {getWindow} from './getWindow';

export function getComputedStyle(element: Element): CSSStyleDeclaration {
return getWindow(element).getComputedStyle(element);
}
5 changes: 5 additions & 0 deletions src/modules/esl-popup/core/getDocumentElement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function getDocumentElement(element: Element | Window): Element {
return ((element instanceof Window
? element.document
: element.ownerDocument) || window.document).documentElement;
}
3 changes: 3 additions & 0 deletions src/modules/esl-popup/core/getNodeName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function getNodeName(element?: Node | Window): string {
return element && !(element instanceof Window)? (element.nodeName).toLowerCase() : '';
}
12 changes: 12 additions & 0 deletions src/modules/esl-popup/core/getParentNode.ts
Original file line number Diff line number Diff line change
@@ -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);
}
15 changes: 15 additions & 0 deletions src/modules/esl-popup/core/getScrollParent.ts
Original file line number Diff line number Diff line change
@@ -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));
}
12 changes: 12 additions & 0 deletions src/modules/esl-popup/core/getWindow.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 7 additions & 0 deletions src/modules/esl-popup/core/isScrollParent.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading

0 comments on commit 9242fca

Please sign in to comment.