@exadel/esl
Version:
Exadel Smart Library (ESL) is the lightweight custom elements library that provide a set of super-flexible components
351 lines (350 loc) • 14.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 { 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';
/**
* 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 ?
this.$$find(this.target) :
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 };