UNPKG

@exadel/esl

Version:

Exadel Smart Library (ESL) is the lightweight custom elements library that provide a set of super-flexible components

352 lines (351 loc) 14.4 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 { ESLBaseElement } from '../../esl-base-element/core'; import { ExportNs } from '../../esl-utils/environment/export-ns'; import { isElement } from '../../esl-utils/dom/api'; import { isRelativeNode } from '../../esl-utils/dom/traversing'; import { isRTL, normalizeScrollLeft } from '../../esl-utils/dom/rtl'; import { getTouchPoint, getOffsetPoint } from '../../esl-utils/dom/events'; import { bind, ready, attr, boolAttr, listen } from '../../esl-utils/decorators'; import { rafDecorator } from '../../esl-utils/async/raf'; import { ESLTraversingQuery } from '../../esl-traversing-query/core'; /** * ESLScrollbar is a reusable web component that replaces the browser's default scrollbar with * a custom scrollbar implementation. * * @author Yuliya Adamskaya */ let ESLScrollbar = class ESLScrollbar extends ESLBaseElement { constructor() { super(...arguments); this._deferredDrag = rafDecorator((e) => this._onPointerDrag(e)); this._deferredRefresh = rafDecorator(() => this.refresh()); this._scrollTimer = 0; this._resizeObserver = new ResizeObserver(this._deferredRefresh); this._mutationObserver = new MutationObserver((rec) => this.updateContentObserve(rec)); } connectedCallback() { super.connectedCallback(); this.render(); this.findTarget(); } disconnectedCallback() { this.unbindTargetEvents(); this._scrollTimer && window.clearTimeout(this._scrollTimer); } attributeChangedCallback(attrName, oldVal, newVal) { if (!this.connected || oldVal === newVal) return; if (attrName === 'target') this.findTarget(); if (attrName === 'horizontal') this.refresh(); } findTarget() { this.$target = this.target ? ESLTraversingQuery.first(this.target, this) : null; } /** Target element to observe and scroll */ get $target() { return this._$target || null; } set $target(content) { this.unbindTargetEvents(); this._$target = content; this.bindTargetEvents(); this._deferredRefresh(); } render() { this.innerHTML = ''; this.$scrollbarTrack = document.createElement('div'); this.$scrollbarTrack.className = this.trackClass; this.$scrollbarThumb = document.createElement('div'); this.$scrollbarThumb.className = this.thumbClass; this.$scrollbarTrack.appendChild(this.$scrollbarThumb); this.appendChild(this.$scrollbarTrack); } bindTargetEvents() { if (!this.$target) return; // Container resize/scroll observers/listeners if (document.documentElement === this.$target) { this.$$on({ event: 'scroll resize', target: window }, this._onScrollOrResize); } else { this.$$on({ event: 'scroll', target: this.$target }, this._onScrollOrResize); this._resizeObserver.observe(this.$target); } // Subscribes to the child elements resizes this._mutationObserver.observe(this.$target, { childList: true }); Array.from(this.$target.children).forEach((el) => this._resizeObserver.observe(el)); } /** Resubscribes resize observer on child elements when container content changes */ updateContentObserve(recs = []) { if (!this.$target) return; const contentChanges = recs.filter((rec) => rec.type === 'childList'); contentChanges.forEach((rec) => { Array.from(rec.addedNodes).filter(isElement).forEach((el) => this._resizeObserver.observe(el)); Array.from(rec.removedNodes).filter(isElement).forEach((el) => this._resizeObserver.unobserve(el)); }); if (contentChanges.length) this._deferredRefresh(); } unbindTargetEvents() { if (!this.$target) return; this.$$off(this._onScrollOrResize); if (document.documentElement !== this.$target) { this._resizeObserver.disconnect(); this._mutationObserver.disconnect(); } } /** @readonly Scrollable distance size value (px) */ get scrollableSize() { if (!this.$target) return 0; return this.horizontal ? this.$target.scrollWidth - this.$target.clientWidth : this.$target.scrollHeight - this.$target.clientHeight; } /** @readonly Track size value (px) */ get trackOffset() { return this.horizontal ? this.$scrollbarTrack.offsetWidth : this.$scrollbarTrack.offsetHeight; } /** @readonly Thumb size value (px) */ get thumbOffset() { return this.horizontal ? this.$scrollbarThumb.offsetWidth : this.$scrollbarThumb.offsetHeight; } /** @readonly Relative thumb size value (between 0.0 and 1.0) */ get thumbSize() { // behave as native scroll if (!this.$target || !this.$target.scrollWidth || !this.$target.scrollHeight) return 1; const areaSize = this.horizontal ? this.$target.clientWidth : this.$target.clientHeight; const scrollSize = this.horizontal ? this.$target.scrollWidth : this.$target.scrollHeight; return Math.min((areaSize + 1) / scrollSize, 1); } /** Relative position value (between 0.0 and 1.0) */ get position() { if (!this.$target) return 0; const size = this.scrollableSize; if (size <= 0) return 0; const offset = this.horizontal ? normalizeScrollLeft(this.$target) : this.$target.scrollTop; if (offset < 1) return 0; if (offset >= size - 1) return 1; return offset / size; } set position(position) { this.scrollTargetTo(this.scrollableSize * this.normalizePosition(position)); this.refresh(); } /** Normalizes position value (between 0.0 and 1.0) */ normalizePosition(position) { const relativePosition = Math.min(1, Math.max(0, position)); if (!isRTL(this.$target) || !this.horizontal) return relativePosition; return relativePosition - 1; } /** Scrolls target element to passed position */ scrollTargetTo(pos) { if (!this.$target) return; this.$target.scrollTo({ [this.horizontal ? 'left' : 'top']: pos, behavior: this.dragging ? 'auto' : 'smooth' }); } /** Updates thumb size and position */ update() { if (!this.$scrollbarThumb || !this.$scrollbarTrack) return; const thumbSize = this.trackOffset * this.thumbSize; const thumbPosition = (this.trackOffset - thumbSize) * this.position; const style = { [this.horizontal ? 'left' : 'top']: `${thumbPosition}px`, [this.horizontal ? 'width' : 'height']: `${thumbSize}px` }; Object.assign(this.$scrollbarThumb.style, style); } /** Updates auxiliary markers */ updateMarkers() { const { position, thumbSize } = this; this.toggleAttribute('at-start', thumbSize < 1 && position <= 0); this.toggleAttribute('at-end', thumbSize < 1 && position >= 1); this.toggleAttribute('inactive', thumbSize >= 1); } /** Refreshes scroll state and position */ refresh() { this.update(); this.updateMarkers(); this.$$fire('esl:change:scroll', { bubbles: false }); } /** Returns position from PointerEvent coordinates (not normalized) */ toPosition(event) { const { horizontal, thumbOffset, trackOffset } = this; const point = getTouchPoint(event); const offset = getOffsetPoint(this.$scrollbarTrack); const pointPosition = horizontal ? point.x - offset.x : point.y - offset.y; const freeTrackArea = trackOffset - thumbOffset; // size of free track px const clickPositionNoOffset = pointPosition - thumbOffset / 2; return clickPositionNoOffset / freeTrackArea; } // Event listeners /** Handles `pointerdown` event to manage thumb drag start and scroll clicks */ _onPointerDown(event) { this._initialPosition = this.position; this._pointerPosition = this.toPosition(event); this._initialMousePosition = this.horizontal ? event.pageX - window.scrollX : event.pageY - window.scrollY; if (event.target === this.$scrollbarThumb) { this._onThumbPointerDown(event); // Drag start handler } else { this._onPointerDownTick(true); // Continuous scroll and click handler } this.$$on(this._onPointerUp); } /** Handles a scroll click / continuous scroll*/ _onPointerDownTick(first) { this._scrollTimer && window.clearTimeout(this._scrollTimer); const position = this.position; const allowedOffset = (first ? 1 : 1.5) * this.thumbSize; this.position = Math.min(position + allowedOffset, Math.max(position - allowedOffset, this._pointerPosition)); if (this.position === this._pointerPosition || this.noContinuousScroll) return; this._scrollTimer = window.setTimeout(this._onPointerDownTick, 400); } /** Handles thumb drag start */ _onThumbPointerDown(event) { var _a; this.toggleAttribute('dragging', true); (_a = this.$target) === null || _a === void 0 ? void 0 : _a.style.setProperty('scroll-behavior', 'auto'); // Attaches drag listeners this.$$on(this._onBodyClick); this.$$on(this._onPointerMove); } /** Sets position on drag */ _onPointerDrag(event) { const point = getTouchPoint(event); const mousePosition = this.horizontal ? point.x - window.scrollX : point.y - window.scrollY; const positionChange = mousePosition - this._initialMousePosition; const scrollableAreaHeight = this.trackOffset - this.thumbOffset; const absChange = scrollableAreaHeight ? (positionChange / scrollableAreaHeight) : 0; this.position = this._initialPosition + absChange; } /** `pointermove` document handler for thumb drag event. Active only if drag action is active */ _onPointerMove(event) { if (!this.dragging) return; // Request position update this._deferredDrag(event); this.setPointerCapture(event.pointerId); } /** `pointerup` / `pointercancel` short-time document handler for drag end action */ _onPointerUp(event) { var _a; this._scrollTimer && window.clearTimeout(this._scrollTimer); this.toggleAttribute('dragging', false); (_a = this.$target) === null || _a === void 0 ? void 0 : _a.style.removeProperty('scroll-behavior'); // Unbinds drag listeners this.$$off(this._onPointerMove); this.$$off(this._onPointerUp); if (this.hasPointerCapture(event.pointerId)) this.releasePointerCapture(event.pointerId); } /** Body `click` short-time handler to prevent clicks event on thumb drag. Handles capture phase */ _onBodyClick(event) { event.stopImmediatePropagation(); } /** * Handler for refresh event * @param event - instance of 'esl:refresh' event. */ _onRefresh(event) { if (!isElement(event.target)) return; if (!isRelativeNode(event.target.parentNode, this.$target)) return; this._deferredRefresh(); } /** * Handler for scroll and resize events * @param event - instance of 'resize' or 'scroll' event */ _onScrollOrResize(event) { if (event.type === 'scroll' && this.dragging) return; this._deferredRefresh(); } }; ESLScrollbar.is = 'esl-scrollbar'; ESLScrollbar.observedAttributes = ['target', 'horizontal']; __decorate([ boolAttr() ], ESLScrollbar.prototype, "horizontal", void 0); __decorate([ boolAttr() ], ESLScrollbar.prototype, "noContinuousScroll", void 0); __decorate([ attr({ defaultValue: '::parent' }) ], ESLScrollbar.prototype, "target", void 0); __decorate([ attr({ defaultValue: 'scrollbar-thumb' }) ], ESLScrollbar.prototype, "thumbClass", void 0); __decorate([ attr({ defaultValue: 'scrollbar-track' }) ], ESLScrollbar.prototype, "trackClass", void 0); __decorate([ boolAttr({ readonly: true }) ], ESLScrollbar.prototype, "dragging", void 0); __decorate([ boolAttr({ readonly: true }) ], ESLScrollbar.prototype, "inactive", void 0); __decorate([ boolAttr({ readonly: true }) ], ESLScrollbar.prototype, "atStart", void 0); __decorate([ boolAttr({ readonly: true }) ], ESLScrollbar.prototype, "atEnd", void 0); __decorate([ ready ], ESLScrollbar.prototype, "connectedCallback", null); __decorate([ ready ], ESLScrollbar.prototype, "disconnectedCallback", null); __decorate([ listen('pointerdown') ], ESLScrollbar.prototype, "_onPointerDown", null); __decorate([ bind ], ESLScrollbar.prototype, "_onPointerDownTick", null); __decorate([ bind ], ESLScrollbar.prototype, "_onThumbPointerDown", null); __decorate([ listen({ event: 'pointermove', auto: false }) ], ESLScrollbar.prototype, "_onPointerMove", null); __decorate([ listen({ event: 'pointerup pointercancel', target: window, auto: false }) ], ESLScrollbar.prototype, "_onPointerUp", null); __decorate([ listen({ auto: false, event: 'click', target: window, once: true, capture: true }) ], ESLScrollbar.prototype, "_onBodyClick", null); __decorate([ listen({ event: (el) => el.REFRESH_EVENT, target: window }) ], ESLScrollbar.prototype, "_onRefresh", null); ESLScrollbar = __decorate([ ExportNs('Scrollbar') ], ESLScrollbar); export { ESLScrollbar };