UNPKG

@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
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 };