@exadel/esl
Version:
Exadel Smart Library (ESL) is the lightweight custom elements library that provide a set of super-flexible components
403 lines (402 loc) • 16.2 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;
};
var ESLPopup_1;
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 { 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, toBooleanAttribute } from '../../esl-utils/misc/format';
import { copy } from '../../esl-utils/misc/object/copy';
import { ESLIntersectionTarget, ESLIntersectionEvent } from '../../esl-event-listener/core/targets/intersection.target';
import { calcPopupPosition, isOnHorizontalAxis } from './esl-popup-position';
import { ESLPopupPlaceholder } from './esl-popup-placeholder';
import { ESL_POPUP_CONFIG_KEYS } from './esl-popup-types';
const INTERSECTION_LIMIT_FOR_ADJACENT_AXIS = 0.7;
let ESLPopup = ESLPopup_1 = class ESLPopup extends ESLToggleable {
constructor() {
super(...arguments);
this._params = {};
this._intersectionRatio = {};
}
get config() {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const $popup = this;
return new Proxy({}, {
get(target, p) {
var _a;
return (_a = Reflect.get($popup._params, p)) !== null && _a !== void 0 ? _a : Reflect.get($popup, p);
},
set: () => false,
deleteProperty: () => false,
has(target, p) {
return Reflect.has($popup._params, p) || Reflect.has($popup, p);
},
ownKeys(target) {
const paramKeys = Reflect.ownKeys($popup._params);
const popupKeys = $popup.constructor.CONFIG_KEYS
.filter((key) => !paramKeys.includes(key) && Reflect.has($popup, key));
return [...paramKeys, ...popupKeys];
},
getOwnPropertyDescriptor: () => ({ enumerable: true, configurable: true })
});
}
/** Arrow element */
get $arrow() {
return this.querySelector(`.${this.arrowClass}`) || this.appendArrow();
}
/** Container element that define bounds of popups visibility */
get $container() {
const { container } = this.config;
return container ? this.$$find(container) : this.config.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 tether ratio */
get offsetTetherRatio() {
const { alignmentTether } = this.config;
if (!['start', 'end'].includes(alignmentTether))
return 0.5;
const ratio = (alignmentTether === 'end') ? 1 : 0;
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);
}
this._params = copy(params, (key) => this.constructor.CONFIG_KEYS.includes(key));
super.onShow(params);
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);
this._params = {};
}
/**
* Actions to execute after showing of popup.
*/
afterOnShow(params) {
this._updatePosition();
this.style.visibility = 'visible';
this.style.cssText += this.config.extraStyle || '';
this.$$cls(this.config.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.config.extraClass || '', false);
this.$$off({ group: 'observer' });
memoize.clear(this, ['offsetTetherRatio', '$container']);
}
get scrollTargets() {
if (this.activator) {
return getListScrollParents(this.activator).concat([window]);
}
return [window];
}
get intersectionOptions() {
return {
rootMargin: this.config.intersectionMargin,
threshold: range(9, (x) => x / 8)
};
}
/** Actions to execute on activator intersection event. */
_onActivatorIntersection(event) {
this._intersectionRatio = {};
if (!event.isIntersecting) {
this.hide({ hideDelay: 0 });
return;
}
const isHorizontal = isOnHorizontalAxis(this.config.position);
const checkIntersection = (isMajorAxis, intersectionRatio) => {
if (isMajorAxis && intersectionRatio < INTERSECTION_LIMIT_FOR_ADJACENT_AXIS)
this.hide({ hideDelay: 0 });
};
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 (!this.open || !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 { $arrow, activator, containerRect, offsetTetherRatio } = this;
const { position, positionOrigin, behavior, offsetContainer, offsetPlacement, marginTether, offsetTrigger } = this.config;
const [placement, alignment] = position.split(/\s+/);
const popupRect = Rect.from(this);
const arrowRect = $arrow ? Rect.from($arrow) : new Rect();
const triggerRect = activator ? Rect.from(activator).shift(window.scrollX, window.scrollY) : new Rect();
const innerMargin = offsetTrigger + arrowRect.width / 2;
return {
placement,
alignment,
hasInnerOrigin: positionOrigin === 'inner',
behavior,
marginTether,
offsetTetherRatio,
offsetPlacement,
intersectionRatio: this._intersectionRatio,
arrow: arrowRect,
element: popupRect,
trigger: triggerRect,
inner: positionOrigin === 'inner' ? triggerRect.shrink(innerMargin) : triggerRect.grow(innerMargin),
outer: (typeof offsetContainer === 'number') ?
containerRect.shrink(offsetContainer) :
containerRect.shrink(...(offsetContainer !== null && offsetContainer !== void 0 ? offsetContainer : [this.constructor.DEFAULT_PARAMS.offsetContainer])),
isRTL: isRTL(this)
};
}
/** Updates position of popup and its arrow */
_updatePosition() {
if (!this.activator || !this.open)
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.config.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'
};
/** List of config keys */
ESLPopup.CONFIG_KEYS = ESL_POPUP_CONFIG_KEYS;
__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()
], ESLPopup.prototype, "alignmentTether", void 0);
__decorate([
attr({ defaultValue: 5, parser: parseInt })
], ESLPopup.prototype, "marginTether", void 0);
__decorate([
attr({ defaultValue: 0, parser: parseInt })
], ESLPopup.prototype, "offsetPlacement", 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, "config", null);
__decorate([
memoize()
], ESLPopup.prototype, "$arrow", null);
__decorate([
memoize()
], ESLPopup.prototype, "$container", null);
__decorate([
ready
], ESLPopup.prototype, "connectedCallback", null);
__decorate([
memoize()
], ESLPopup.prototype, "offsetTetherRatio", null);
__decorate([
listen({
auto: false,
group: 'observer',
event: ESLIntersectionEvent.TYPE,
target: ($popup) => $popup.activator ? ESLIntersectionTarget.for($popup.activator, $popup.intersectionOptions) : [],
condition: ($popup) => !$popup.config.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 = ESLPopup_1 = __decorate([
ExportNs('Popup')
], ESLPopup);
export { ESLPopup };