UNPKG

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