vevet
Version:
Vevet is a JavaScript library for creative development that simplifies crafting rich interactions like split text animations, carousels, marquees, preloading, and more.
461 lines (368 loc) • 11.3 kB
text/typescript
import { TRequiredProps } from '@/internal/requiredProps';
import { Module } from '@/base';
import {
IScrollbarCallbacksMap,
IScrollbarMutableProps,
IScrollbarStaticProps,
} from './types';
import { initVevet } from '@/global/initVevet';
import { addEventListener, clamp, onResize, toPixels } from '@/utils';
import { createScrollbarStyles } from './styles';
import { ISwipeCoords, Swipe } from '../Swipe';
export * from './types';
/**
* A custom scrollbar component. Supports both `window` and `HTMLElement` containers.
*
* [Documentation](https://antonbobrov.github.io/vevet/docs/components/Scrollbar)
*
* @group Components
*/
export class Scrollbar<
CallbacksMap extends IScrollbarCallbacksMap = IScrollbarCallbacksMap,
StaticProps extends IScrollbarStaticProps = IScrollbarStaticProps,
MutableProps extends IScrollbarMutableProps = IScrollbarMutableProps,
> extends Module<CallbacksMap, StaticProps, MutableProps> {
/** Get default static properties. */
public _getStatic(): TRequiredProps<StaticProps> {
return {
...super._getStatic(),
container: window,
parent: false,
class: false,
axis: 'y',
draggable: true,
autoHide: true,
resizeDebounce: 0,
} as TRequiredProps<StaticProps>;
}
/** Get default mutable properties. */
public _getMutable(): TRequiredProps<MutableProps> {
return {
...super._getMutable(),
minSize: 50,
autoSize: true,
} as TRequiredProps<MutableProps>;
}
get prefix() {
return `${initVevet().prefix}scrollbar`;
}
/**
* The element to which the scrollbar is applied.
*/
get container() {
return this._props.container;
}
/**
* Scrollbar outer element.
*/
protected _outer!: HTMLElement;
/**
* Scrollbar outer element
*/
get outer() {
return this._outer;
}
/**
* Scrollbar track element (the container of the thumb).
*/
protected _track!: HTMLElement;
/**
* Scrollbar track element (the container of the thumb).
*/
get track() {
return this._track;
}
/**
* Scrollbar thumb element (draggable handle).
*/
protected _thumb!: HTMLElement;
/**
* Scrollbar thumb element (draggable handle).
*/
get thumb() {
return this._thumb;
}
/** Save scroll value on swipe start */
protected _valueOnSwipeStart = 0;
/** Timeout for scroll action */
protected _addInActionTimeout?: NodeJS.Timeout;
/** Timeout for scroll action */
protected _removeInActionTimeout?: NodeJS.Timeout;
/** Previous scroll value */
protected _prevScrollValue = 0;
constructor(props?: StaticProps & MutableProps) {
super(props);
// No need to remove styles on destroy
createScrollbarStyles(this.prefix);
// Create elements
this._create();
// Set events
this._setResize();
this._setOnscroll();
this._setSwipe();
}
/** Handles property mutations */
protected _handleProps() {
super._handleProps();
this.resize();
}
/** Scroll axis */
get axis() {
return this.props.axis;
}
/**
* The element where the scrollbar is appended.
* If `parent` is not set, it defaults to `container` or `document.body` (if applied to `window`).
*/
get parent() {
const { parent, container } = this.props;
return (
parent || (container instanceof Window ? initVevet().body : container)
);
}
/**
* The actual scrollable element.
* Returns `document.documentElement` for `window`, otherwise the `container` itself.
*/
get scrollElement() {
return this.container instanceof Window ? initVevet().html : this.container;
}
/**
* Returns the total scroll width/height of the content.
*/
get scrollSize() {
const { scrollElement } = this;
return this.axis === 'x'
? scrollElement.scrollWidth
: scrollElement.scrollHeight;
}
/**
* Returns the total scrollable distance.
*/
get scrollableSize() {
const { scrollElement } = this;
return this.axis === 'x'
? this.scrollSize - scrollElement.clientWidth
: this.scrollSize - scrollElement.clientHeight;
}
/**
* Returns scrollTop or scrollLeft of the scrollable element.
*/
get scrollValue() {
const { axis } = this;
if (this.container instanceof Window) {
return axis === 'x' ? window.scrollX : window.scrollY;
}
return axis === 'x' ? this.container.scrollLeft : this.container.scrollTop;
}
/** Returns the current track size. */
get trackSize() {
return this.axis === 'x'
? this._track.offsetWidth
: this._track.offsetHeight;
}
/** Returns the current thumb size. */
get thumbSize() {
return this.axis === 'x'
? this._thumb.offsetWidth
: this._thumb.offsetHeight;
}
/** Create elements */
protected _create() {
const app = initVevet();
const { parent, scrollElement } = this;
const isInWindow = this.container instanceof Window;
this._outer = this._createOuter();
parent.appendChild(this._outer);
this._track = this._createTrack();
this._outer.appendChild(this._track);
this._thumb = this._createThumb();
this._track.appendChild(this._thumb);
// Apply global styles
if (isInWindow) {
this._addTempClassName(app.html, this._cn('-scrollable'));
this._addTempClassName(app.body, this._cn('-scrollable'));
} else {
this._addTempClassName(scrollElement, this._cn('-scrollable'));
}
this.onDestroy(() => this._outer.remove());
}
/** Create outer element */
protected _createOuter() {
const cn = this._cn.bind(this);
const { props, axis } = this;
const element = document.createElement('div');
element.classList.add(cn(''));
element.classList.add(cn(`_${axis}`));
if (props.class) {
element.classList.add(props.class);
}
if (this.container instanceof Window) {
this._addTempClassName(element, this._cn('_in-window'));
}
if (props.autoHide) {
this._addTempClassName(element, this._cn('_auto-hide'));
}
return element;
}
/** Create track element */
protected _createTrack() {
const cn = this._cn.bind(this);
const { axis } = this;
const element = document.createElement('div');
element.classList.add(cn('__track'));
element.classList.add(cn(`__track_${axis}`));
return element;
}
/** Create thumb element */
protected _createThumb() {
const cn = this._cn.bind(this);
const element = document.createElement('div');
element.classList.add(cn('__thumb'));
element.classList.add(cn(`__thumb_${this.axis}`));
return element;
}
/** Set resize events */
protected _setResize() {
const handler = onResize({
element: [this.track, this.parent, this.scrollElement],
resizeDebounce: this.props.resizeDebounce,
callback: () => this.resize(),
});
handler.resize();
this.onDestroy(() => handler.remove());
}
/** Set scroll events */
protected _setOnscroll() {
const handler = addEventListener(
this.container,
'scroll',
() => this._onScroll(),
{
passive: true,
},
);
this.onDestroy(() => handler());
}
/** Set swipe events */
protected _setSwipe() {
if (!this.props.draggable) {
return;
}
const swipe = new Swipe({ container: this.thumb, grabCursor: true });
swipe.on('start', (coords) => {
this._valueOnSwipeStart = this.scrollValue;
this.callbacks.emit('swipeStart', coords);
});
swipe.on('move', (coords) => {
this._onSwipeMove(coords);
this.callbacks.emit('swipe', coords);
});
swipe.on('end', (coords) => {
this.callbacks.emit('swipeEnd', coords);
});
swipe.on('touchmove', (event) => {
event.stopPropagation();
event.stopImmediatePropagation();
});
swipe.on('mousemove', (event) => {
event.stopPropagation();
event.stopImmediatePropagation();
});
this.onDestroy(() => swipe.destroy());
}
/** Resize the scrollbar. */
public resize() {
const { scrollableSize, scrollSize, outer, track, thumb, props, axis } =
this;
const { autoSize: shouldAutoSize } = props;
const isHorizontal = axis === 'x';
// Define if the scrollbar is empty
outer.classList.toggle(this._cn('_empty'), scrollableSize === 0);
// Save sizes
const trackSize = isHorizontal ? track.offsetWidth : track.offsetHeight;
// Calculate minimum thumb size
const minThumbSize = toPixels(props.minSize);
let newThumbSize = minThumbSize;
// Calculate thumb sizes if auto-size is enabled
if (shouldAutoSize) {
newThumbSize = clamp(
trackSize / (scrollSize / trackSize),
minThumbSize,
Infinity,
);
}
// Apply sizes
if (isHorizontal) {
thumb.style.width = `${newThumbSize}px`;
} else {
thumb.style.height = `${newThumbSize}px`;
}
// Reset timeouts
if (this._addInActionTimeout) {
clearTimeout(this._addInActionTimeout);
}
// Render the scrollbar
this._render();
// Emit callbacks
this.callbacks.emit('resize', undefined);
}
/** Render the scrollbar. */
protected _render() {
const { scrollValue, scrollableSize, axis, thumbSize, trackSize } = this;
const scrollProgress = clamp(scrollValue / scrollableSize);
const translate = (trackSize - thumbSize) * scrollProgress;
const x = axis === 'x' ? translate : 0;
const y = axis === 'y' ? translate : 0;
this._thumb.style.transform = `translate(${x}px, ${y}px)`;
// Emit callbacks
this.callbacks.emit('update', undefined);
}
/** Handle scroll update */
protected _onScroll() {
const { scrollValue, outer } = this;
const inActionClass = this._cn('_in-action');
if (scrollValue !== this._prevScrollValue) {
this._addInActionTimeout = setTimeout(() => {
if (!outer.classList.contains(inActionClass)) {
outer.classList.add(inActionClass);
this.callbacks.emit('show', undefined);
}
}, 50);
} else {
this._prevScrollValue = scrollValue;
}
this._render();
if (this._removeInActionTimeout) {
clearTimeout(this._removeInActionTimeout);
}
this._removeInActionTimeout = setTimeout(() => {
outer.classList.remove(inActionClass);
this.callbacks.emit('hide', undefined);
}, 500);
}
/** Handle swipe move */
protected _onSwipeMove({ diff }: ISwipeCoords) {
const { scrollElement, axis, trackSize, thumbSize, scrollableSize } = this;
const diffCoord = axis === 'x' ? diff.x : diff.y;
const iterator = (diffCoord / (trackSize - thumbSize)) * scrollableSize;
const target = this._valueOnSwipeStart + iterator;
scrollElement.scrollTo({
top: axis === 'y' ? target : undefined,
left: axis === 'x' ? target : undefined,
behavior: 'instant',
});
}
/**
* Destroys the component and cleans up resources.
*/
protected _destroy() {
super._destroy();
if (this._addInActionTimeout) {
clearTimeout(this._addInActionTimeout);
}
if (this._removeInActionTimeout) {
clearTimeout(this._removeInActionTimeout);
}
}
}