@exadel/esl
Version:
Exadel Smart Library (ESL) is the lightweight custom elements library that provide a set of super-flexible components
380 lines (379 loc) • 15.3 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
import { range } from '../../esl-utils/misc/array';
import { ExportNs } from '../../esl-utils/environment/export-ns';
import { bind, memoize, ready, attr, boolAttr, jsonAttr, listen, decorate } from '../../esl-utils/decorators';
import { ESLTraversingQuery } from '../../esl-traversing-query/core';
import { afterNextRender, rafDecorator } from '../../esl-utils/async/raf';
import { ESLToggleable } from '../../esl-toggleable/core';
import { isElement, isRelativeNode, isRTL, Rect, getListScrollParents, getViewportRect } from '../../esl-utils/dom';
import { parseBoolean, parseNumber, toBooleanAttribute } from '../../esl-utils/misc/format';
import { copyDefinedKeys } from '../../esl-utils/misc/object';
import { ESLIntersectionTarget, ESLIntersectionEvent } from '../../esl-event-listener/core/targets/intersection.target';
import { calcPopupPosition, isOnHorizontalAxis } from './esl-popup-position';
import { ESLPopupPlaceholder } from './esl-popup-placeholder';
const INTERSECTION_LIMIT_FOR_ADJACENT_AXIS = 0.7;
const DEFAULT_OFFSET_ARROW = 50;
let ESLPopup = class ESLPopup extends ESLToggleable {
constructor() {
super(...arguments);
this._intersectionRatio = {};
}
/** Arrow element */
get $arrow() {
return this.querySelector(`.${this.arrowClass}`) || this.appendArrow();
}
/** Container element that define bounds of popups visibility */
get $container() {
return this.container ? ESLTraversingQuery.first(this.container, this) : this._containerEl;
}
/** Get the size and position of the container */
get containerRect() {
if (!this.$container)
return getViewportRect();
return Rect.from(this.$container).shift(window.scrollX, window.scrollY);
}
connectedCallback() {
super.connectedCallback();
this.moveToBody();
}
disconnectedCallback() {
super.disconnectedCallback();
memoize.clear(this, '$arrow');
}
/** Get offsets arrow ratio */
get offsetArrowRatio() {
const offset = parseNumber(this.offsetArrow, DEFAULT_OFFSET_ARROW);
const offsetNormalized = Math.max(0, Math.min(offset, 100));
const ratio = offsetNormalized / 100;
return isRTL(this) ? 1 - ratio : ratio;
}
/** Moves popup into document.body */
moveToBody() {
var _a;
const { parentNode, $placeholder } = this;
if (!parentNode || parentNode === document.body)
return;
// to be safe and prevent leaks
$placeholder && ((_a = $placeholder.parentNode) === null || _a === void 0 ? void 0 : _a.removeChild($placeholder));
// replace this with placeholder element
this.$placeholder = ESLPopupPlaceholder.from(this);
parentNode.replaceChild(this.$placeholder, this);
document.body.appendChild(this);
}
/** Appends arrow to Popup */
appendArrow() {
const $arrow = document.createElement('span');
$arrow.className = this.arrowClass;
this.appendChild($arrow);
memoize.clear(this, '$arrow');
return $arrow;
}
/** Runs additional actions on show popup request */
shouldShow(params) {
if (params.activator !== this.activator)
return true;
return super.shouldShow(params);
}
/**
* Actions to execute on show popup.
* Inner state and 'open' attribute are not affected and updated before `onShow` execution.
* Adds CSS classes, update a11y and fire esl:refresh event by default.
*/
onShow(params) {
const wasOpened = this.open;
if (wasOpened) {
this.beforeOnHide(params);
this.afterOnHide(params);
}
super.onShow(params);
// TODO: change flow to use merged params unless attribute state is used in CSS
Object.assign(this, copyDefinedKeys({
position: params.position,
positionOrigin: params.positionOrigin,
behavior: params.behavior,
container: params.container,
marginArrow: params.marginArrow,
offsetArrow: params.offsetArrow,
offsetTrigger: params.offsetTrigger,
disableActivatorObservation: params.disableActivatorObservation
}));
this._extraClass = params.extraClass;
this._extraStyle = params.extraStyle;
this._containerEl = params.containerEl;
this._offsetContainer = params.offsetContainer || 0;
this._intersectionMargin = params.intersectionMargin || '0px';
this.style.visibility = 'hidden'; // eliminates the blinking of the popup at the previous position
// running as a separate task solves the problem with incorrect positioning on the first showing
if (wasOpened)
this.afterOnShow(params);
else
afterNextRender(() => this.afterOnShow(params));
}
/**
* Actions to execute on hide popup.
* Inner state and 'open' attribute are not affected and updated before `onShow` execution.
* Removes CSS classes and updates a11y by default.
*/
onHide(params) {
this.beforeOnHide(params);
super.onHide(params);
this.afterOnHide(params);
}
/**
* Actions to execute after showing of popup.
*/
afterOnShow(params) {
this._updatePosition();
this.style.visibility = 'visible';
this.style.cssText += this._extraStyle || '';
this.$$cls(this._extraClass || '', true);
this.$$on(this._onActivatorScroll);
this.$$on(this._onActivatorIntersection);
this.$$on(this._onTransitionStart);
this.$$on(this._onResize);
this.$$on(this._onRefresh);
this._startUpdateLoop();
}
/**
* Actions to execute before hiding of popup.
*/
beforeOnHide(params) { }
/**
* Actions to execute after hiding of popup.
*/
afterOnHide(params) {
this._stopUpdateLoop();
this.$$attr('style', '');
this.$$cls(this._extraClass || '', false);
this.$$off({ group: 'observer' });
memoize.clear(this, ['offsetArrowRatio', '$container']);
}
get scrollTargets() {
if (this.activator) {
return getListScrollParents(this.activator).concat([window]);
}
return [window];
}
get intersectionOptions() {
return {
rootMargin: this._intersectionMargin,
threshold: range(9, (x) => x / 8)
};
}
/** Actions to execute on activator intersection event. */
_onActivatorIntersection(event) {
this._intersectionRatio = {};
if (!event.isIntersecting) {
this.hide();
return;
}
const isHorizontal = isOnHorizontalAxis(this.position);
const checkIntersection = (isMajorAxis, intersectionRatio) => {
if (isMajorAxis && intersectionRatio < INTERSECTION_LIMIT_FOR_ADJACENT_AXIS)
this.hide();
};
if (event.intersectionRect.y !== event.boundingClientRect.y) {
this._intersectionRatio.top = event.intersectionRect.height / event.boundingClientRect.height;
checkIntersection(isHorizontal, this._intersectionRatio.top);
}
if (event.intersectionRect.bottom !== event.boundingClientRect.bottom) {
this._intersectionRatio.bottom = event.intersectionRect.height / event.boundingClientRect.height;
checkIntersection(isHorizontal, this._intersectionRatio.bottom);
}
if (event.intersectionRect.x !== event.boundingClientRect.x) {
this._intersectionRatio.left = event.intersectionRect.width / event.boundingClientRect.width;
checkIntersection(!isHorizontal, this._intersectionRatio.left);
}
if (event.intersectionRect.right !== event.boundingClientRect.right) {
this._intersectionRatio.right = event.intersectionRect.width / event.boundingClientRect.width;
checkIntersection(!isHorizontal, this._intersectionRatio.right);
}
}
/** Actions to execute on activator scroll event. */
_onActivatorScroll(e) {
if (this._updateLoopID)
return;
this._updatePosition();
}
_onTransitionStart() {
this._startUpdateLoop();
}
_onResize() {
this._updatePosition();
}
_onRefresh({ target }) {
if (!isElement(target))
return;
const { activator, $container } = this;
if ($container === target || this.contains(target) || isRelativeNode(activator, target))
this._updatePosition();
}
/**
* Starts loop for update position of popup.
* The loop ends when the position and size of the activator have not changed
* for the last 2 frames of the animation.
*/
_startUpdateLoop() {
if (this._updateLoopID)
return;
let same = 0;
let lastRect = new Rect();
const updateLoop = () => {
if (!this.activator)
return this._stopUpdateLoop();
const newRect = Rect.from(this.activator.getBoundingClientRect());
if (!Rect.isEqual(lastRect, newRect)) {
same = 0;
lastRect = newRect;
}
if (same++ > 2)
return this._stopUpdateLoop();
this._updatePosition();
this._updateLoopID = requestAnimationFrame(updateLoop);
};
this._updateLoopID = requestAnimationFrame(updateLoop);
}
/**
* Stops loop for update position of popup.
* Also cancels the animation frame request.
*/
_stopUpdateLoop() {
if (!this._updateLoopID)
return;
cancelAnimationFrame(this._updateLoopID);
this._updateLoopID = 0;
}
get positionConfig() {
const popupRect = Rect.from(this);
const arrowRect = this.$arrow ? Rect.from(this.$arrow) : new Rect();
const triggerRect = this.activator ? Rect.from(this.activator).shift(window.scrollX, window.scrollY) : new Rect();
const { containerRect } = this;
const innerMargin = this.offsetTrigger + arrowRect.width / 2;
return {
position: this.position,
hasInnerOrigin: this.positionOrigin === 'inner',
behavior: this.behavior,
marginArrow: this.marginArrow,
offsetArrowRatio: this.offsetArrowRatio,
intersectionRatio: this._intersectionRatio,
arrow: arrowRect,
element: popupRect,
trigger: triggerRect,
inner: this.positionOrigin === 'inner' ? triggerRect.shrink(innerMargin) : triggerRect.grow(innerMargin),
outer: (typeof this._offsetContainer === 'number') ?
containerRect.shrink(this._offsetContainer) :
containerRect.shrink(...this._offsetContainer),
isRTL: isRTL(this)
};
}
/** Updates position of popup and its arrow */
_updatePosition() {
if (!this.activator)
return;
const { placedAt, popup, arrow } = calcPopupPosition(this.positionConfig);
this.setAttribute('placed-at', placedAt);
// set popup position
this.style.left = `${popup.x}px`;
this.style.top = `${popup.y}px`;
if (!this.$arrow)
return;
// set arrow position
const isHorizontal = isOnHorizontalAxis(this.position);
this.$arrow.style.left = isHorizontal ? '' : `${arrow.x}px`;
this.$arrow.style.top = isHorizontal ? `${arrow.y}px` : '';
}
};
ESLPopup.is = 'esl-popup';
/** Default params to pass into the popup on show/hide actions */
ESLPopup.DEFAULT_PARAMS = {
offsetContainer: 15,
intersectionMargin: '0px'
};
__decorate([
attr({ defaultValue: 'esl-popup-arrow' })
], ESLPopup.prototype, "arrowClass", void 0);
__decorate([
attr({ defaultValue: 'top' })
], ESLPopup.prototype, "position", void 0);
__decorate([
attr({ defaultValue: 'outer' })
], ESLPopup.prototype, "positionOrigin", void 0);
__decorate([
attr({ defaultValue: 'fit' })
], ESLPopup.prototype, "behavior", void 0);
__decorate([
boolAttr()
], ESLPopup.prototype, "disableActivatorObservation", void 0);
__decorate([
attr({ defaultValue: 5, parser: parseInt })
], ESLPopup.prototype, "marginArrow", void 0);
__decorate([
attr({ defaultValue: `${DEFAULT_OFFSET_ARROW}` })
], ESLPopup.prototype, "offsetArrow", void 0);
__decorate([
attr({ defaultValue: 3, parser: parseInt })
], ESLPopup.prototype, "offsetTrigger", void 0);
__decorate([
attr()
], ESLPopup.prototype, "container", void 0);
__decorate([
jsonAttr()
], ESLPopup.prototype, "defaultParams", void 0);
__decorate([
attr({ parser: parseBoolean, serializer: toBooleanAttribute, defaultValue: true })
], ESLPopup.prototype, "closeOnEsc", void 0);
__decorate([
attr({ parser: parseBoolean, serializer: toBooleanAttribute, defaultValue: true })
], ESLPopup.prototype, "closeOnOutsideAction", void 0);
__decorate([
attr({ defaultValue: 'popup' })
], ESLPopup.prototype, "a11y", void 0);
__decorate([
memoize()
], ESLPopup.prototype, "$arrow", null);
__decorate([
memoize()
], ESLPopup.prototype, "$container", null);
__decorate([
ready
], ESLPopup.prototype, "connectedCallback", null);
__decorate([
memoize()
], ESLPopup.prototype, "offsetArrowRatio", null);
__decorate([
listen({
auto: false,
group: 'observer',
event: ESLIntersectionEvent.TYPE,
target: ($popup) => $popup.activator ? ESLIntersectionTarget.for($popup.activator, $popup.intersectionOptions) : [],
condition: ($popup) => !$popup.disableActivatorObservation
})
], ESLPopup.prototype, "_onActivatorIntersection", null);
__decorate([
listen({ auto: false, group: 'observer', event: 'scroll', target: ($popup) => $popup.scrollTargets })
], ESLPopup.prototype, "_onActivatorScroll", null);
__decorate([
listen({ auto: false, group: 'observer', event: 'transitionstart', target: document.body })
], ESLPopup.prototype, "_onTransitionStart", null);
__decorate([
listen({ auto: false, group: 'observer', event: 'resize', target: window }),
decorate(rafDecorator)
], ESLPopup.prototype, "_onResize", null);
__decorate([
listen({ auto: false, group: 'observer', event: ($popup) => $popup.REFRESH_EVENT, target: window })
], ESLPopup.prototype, "_onRefresh", null);
__decorate([
bind
], ESLPopup.prototype, "_startUpdateLoop", null);
__decorate([
bind
], ESLPopup.prototype, "_stopUpdateLoop", null);
ESLPopup = __decorate([
ExportNs('Popup')
], ESLPopup);
export { ESLPopup };