@v4fire/client
Version:
V4Fire client core library
916 lines (746 loc) • 19.7 kB
text/typescript
/*!
* V4Fire Client Core
* https://github.com/V4Fire/Client
*
* Released under the MIT license
* https://github.com/V4Fire/Client/blob/master/LICENSE
*/
/**
* [[include:base/b-slider/README.md]]
* @packageDocumentation
*/
//#if demo
import 'models/demo/list';
//#endif
import symbolGenerator from 'core/symbol';
import { derive } from 'core/functools/trait';
import { deprecated, deprecate } from 'core/functools';
import iObserveDOM from 'traits/i-observe-dom/i-observe-dom';
import iItems, { IterationKey } from 'traits/i-items/i-items';
import iData, {
component,
prop,
field,
system,
computed,
hook,
watch,
wait,
ModsDecl
} from 'super/i-data/i-data';
import { sliderModes, alignTypes, autoSlidingAsyncGroup } from 'base/b-slider/const';
import type { Mode, SlideRect, SlideDirection, AlignType } from 'base/b-slider/interface';
export * from 'super/i-data/i-data';
export * from 'base/b-slider/interface';
export const
$$ = symbolGenerator();
interface bSlider extends Trait<typeof iObserveDOM> {}
/**
* Component to create a content slider
*/
class bSlider extends iData implements iObserveDOM, iItems {
/** @see [[iItems.Item]] */
readonly Item!: object;
/** @see [[iItems.Items]] */
readonly Items!: Array<this['Item']>;
/**
* A slider mode:
*
* 1. With the `slide` mode, it is impossible to skip slides.
* That is, we can't get from the first slide directly to the third or other stuff.
*
* 2. With the `scroll` mode, to scroll slides is used the browser native scrolling.
*/
readonly modeProp: Mode = 'slide';
/**
* If true, the height calculation will be based on rendered elements.
* The component will create an additional element to contain the rendered elements,
* while it will not be visible to the user. This may be useful if you need to hide scroll on mobile devices,
* but you don't know the exact size of the elements that can be rendered into a component.
*/
readonly dynamicHeight: boolean = false;
/**
* If true, a user will be automatically returned to the first slide when scrolling the last slide.
* That is, the slider will work "in a circle".
*/
readonly circular: boolean = false;
/**
* This prop controls how many slides will scroll.
* For example, by specifying `center`, the slider will stop when the active slide is
* in the slider's center when scrolling.
*/
readonly align: AlignType = 'center';
/**
* If true, the first slide will be aligned to the start position (the left bound).
*/
readonly alignFirstToStart: boolean = true;
/**
* If true, the last slide will be aligned to the end position (the right bound).
*/
readonly alignLastToEnd: boolean = true;
/**
* How much does the shift along the X-axis corresponds to a finger movement
*/
readonly deltaX: number = 0.9;
/**
* The minimum required percentage to scroll the slider to another slide
*/
readonly threshold: number = 0.3;
/**
* The minimum required percentage for the scroll slider to another slide in fast motion on the slider
*/
readonly fastSwipeThreshold: number = 0.05;
/**
* Time (in milliseconds) after which we can assume that there was a quick swipe
*/
readonly fastSwipeDelay: number = (0.3).seconds();
/**
* The minimum displacement threshold along the X-axis at which the slider will be considered to be used (in px)
*/
readonly swipeToleranceX: number = 10;
/**
* The minimum Y-axis offset threshold at which the slider will be considered to be used (in px)
*/
readonly swipeToleranceY: number = 50;
/**
* The interval (in ms) between auto slide moves. 0 means no auto slide moves.
*/
readonly autoSlideInterval: number = 0;
/**
* The delay (in ms) between last user gesture and first auto slide move.
* A maximum of `autoSlideInterval` and `autoSlidePostGestureDelay` will be used
* as a timeout for the first auto slide move after user gesture.
*/
readonly autoSlidePostGestureDelay: number = 0;
/**
* @deprecated
* @see [[bSlider.items]]
*/
readonly optionsProp: iItems['items'] = [];
/** @see [[iItems.items]] */
readonly itemsProp: iItems['items'] = [];
/**
* @deprecated
* @see [[bSlider.item]]
*/
readonly option?: iItems['item'];
/** @see [[iItems.item]] */
readonly item?: iItems['item'];
/**
* @deprecated
* @see [[bSlider.itemKey]]
*/
readonly optionKey?: iItems['itemKey'];
/** @see [[iItems.itemKey]] */
readonly itemKey?: iItems['itemKey'];
/**
* @deprecated
* @see [[bSlider.itemProps]]
*/
readonly optionProps?: iItems['itemProps'];
/** @see [[iItems.itemProps]] */
readonly itemProps?: iItems['itemProps'];
/** @see [[bSlider.items]] */
options!: this['Items'];
/**
* The number of slides in the slider
*/
length: number = 0;
static override readonly mods: ModsDecl = {
swipe: [
'true',
'false'
]
};
/**
* Link to a content node
*/
get content(): CanUndef<HTMLElement> {
return this.$refs.content;
}
/**
* Number of DOM nodes within a content block
*/
get contentLength(): number {
const l = this.content;
return l ? l.children.length : 0;
}
/**
* Pointer to the current slide
*/
get current(): number {
return this.currentStore;
}
/**
* Sets a pointer of the current slide
*
* @param value
* @emits `change(current: number)`
*/
set current(value: number) {
if (value === this.current) {
return;
}
this.currentStore = value;
this.emit('change', value);
}
/**
* @deprecated
* @see [[bSlider.isSlideMode]]
*/
get isSlider(): boolean {
return this.isSlideMode;
}
/**
* True if a slider mode is `slide`.
*/
get isSlideMode(): boolean {
return this.mode === 'slide';
}
/**
* The current slider scroll position
*/
get currentOffset(): number {
if (this.mode === 'scroll') {
return this.$refs.contentWrapper?.scrollLeft ?? 0;
}
const
{slideRects, current, align, viewRect} = this,
slidesCount = slideRects.length,
slideRect = slideRects[current];
if (current >= slidesCount || !viewRect) {
return 0;
}
if (this.alignFirstToStart && current === 0) {
return 0;
}
if (this.alignLastToEnd && current === slidesCount - 1 && slidesCount !== 1) {
return slideRect.offsetLeft + slideRect.width - viewRect.width;
}
switch (align) {
case 'center':
return slideRect.offsetLeft - (viewRect.width - slideRect.width) / 2;
case 'start':
return slideRect.offsetLeft;
case 'end':
return slideRect.offsetLeft + slideRect.width - viewRect.width;
default:
return 0;
}
}
/** @see [[iItems.items]] */
protected itemsStore!: iItems['items'];
/** @see [[bSlider.current]] */
protected currentStore: number = 0;
/** @see [[bSlider.modeProp]] */
protected mode!: Mode;
protected override readonly $refs!: {
view?: HTMLElement;
content?: HTMLElement;
contentWrapper?: HTMLElement;
};
/**
* X position of the first touch on the slider
*/
protected startX: number = 0;
/**
* Y position of the first touch on the slider
*/
protected startY: number = 0;
/**
* The difference between a touch position on X axis at the beginning of the swipe and at the end
*/
protected diffX: number = 0;
/**
* Is the minimum threshold for starting slide content passed
*
* @see [[bSlider.swipeToleranceX]]
* @see [[bSlider.swipeToleranceY]]
*/
protected isTolerancePassed: boolean = false;
/**
* Slide positions
*/
protected slideRects: SlideRect[] = [];
/**
* Slider viewport rectangle
*/
protected viewRect?: DOMRect;
/**
* Timestamp of a start touch on the slider
*/
protected startTime: number = 0;
/**
* True if a user has started scrolling
*/
protected scrolling: boolean = true;
/**
* True if a user has started swiping
*/
protected swiping: boolean = false;
/**
* True if all animations need to use `requestAnimationFrame`
*/
protected get shouldUseRAF(): boolean {
return this.browser.is.iOS === false;
}
/**
* True if needed to minimize the amount of non-essential motion used
*/
protected get shouldReduceMotion(): boolean {
return globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
/** @see [[iItems.items]] */
get items(): this['Items'] {
const
items = Object.size(this.options) > 0 ? this.options : this.itemsStore;
if (Object.size(this.options) > 0) {
deprecate({
name: 'options',
type: 'property',
renamedTo: 'items'
});
}
return items ?? [];
}
/**
* @param value
* @see [[iItems.items]]
*/
set items(value: this['Items']) {
this.field.set('itemsStore', value);
}
/**
* Switches to the specified slide by an index
*
* @param index - slide index
* @param [animate] - animate transition
*/
async slideTo(index: number, animate: boolean = false): Promise<boolean> {
const
{length, current, content} = this;
if (current === index || !content) {
return false;
}
if (length - 1 >= index) {
this.current = index;
if (animate) {
await this.removeMod('swipe');
} else {
await this.setMod('swipe', true);
}
this.syncState();
return true;
}
return false;
}
/**
* Moves to the next or previous slide
* @param dir - direction
*/
moveSlide(dir: SlideDirection): boolean {
let
{current} = this;
const
{length, content} = this;
if (dir < 0 && current > 0 || dir > 0 && current < length - 1 || this.circular) {
if (content == null) {
return false;
}
current += dir;
if (dir < 0 && current < 0) {
current = length - 1;
} else if (dir > 0 && current > length - 1) {
current = 0;
}
this.current = current;
this.performSliderMove();
return true;
}
return false;
}
/** @see [[iObserveDOM.initDOMObservers]] */
initDOMObservers(): void {
const
{content} = this;
if (content) {
iObserveDOM.observe(this, {
node: content,
childList: true
});
}
}
/**
* Performs auto slide change.
*/
protected async performAutoSlide(): Promise<void> {
const
{current, length} = this;
if (current === length - 1) {
await this.slideTo(0, true);
} else {
await this.slideTo(current + 1, true);
}
}
/**
* Resets auto slide moves
* @param firstInterval - an interval (in ms) before first auto slide change.
*/
protected initAutoSliding(firstInterval: number = this.autoSlideInterval): void {
this.stopAutoSliding();
if (!this.isSlideMode || !Number.isPositive(firstInterval)) {
return;
}
this.async.setTimeout(
async () => {
await this.performAutoSlide();
this.async.setInterval(
() => this.performAutoSlide(),
this.autoSlideInterval,
{label: $$.autoSlide, group: autoSlidingAsyncGroup, join: false}
);
},
firstInterval,
{label: $$.autoSlideFirst, group: autoSlidingAsyncGroup, join: false}
);
}
/**
* Clears auto slide moves.
*/
protected stopAutoSliding(): void {
this.async.clearAll({group: new RegExp(autoSlidingAsyncGroup)});
}
/**
* Synchronizes auto slide moves by (re-)setting the corresponding interval.
*
* Waiting for `ready` state because slides may be loaded via data provider.
* Watching `db` for possible slide changes and `mode` because
* auto slide only works in `slide` mode.
*/
protected syncAutoSlide(): void {
this.initAutoSliding(this.autoSlideInterval);
}
/**
* Performs the slider animation
*/
protected updateSlidePosition(): void {
const
{content} = this;
if (content == null) {
return;
}
const pos = this.shouldReduceMotion ? this.currentOffset : this.currentOffset + this.diffX * this.deltaX;
content.style.transform = `translate3d(${(-pos).px}, 0, 0)`;
}
/**
* Updates the slider position
*/
protected performSliderMove(): void {
if (this.shouldUseRAF) {
this.async.requestAnimationFrame(this.updateSlidePosition.bind(this), {label: $$.performSliderMove});
} else {
this.updateSlidePosition();
}
}
/**
* Returns additional props to pass to the specified item component
*
* @param el
* @param i
*/
protected getItemAttrs(el: this['Item'], i: number): CanUndef<Dictionary> {
const
{itemProps, optionProps} = this;
let
props = itemProps;
if (optionProps != null) {
deprecate({
name: 'optionProps',
type: 'property',
renamedTo: 'itemProps'
});
props = optionProps;
}
return Object.isFunction(props) ?
props(el, i, {
key: this.getItemKey(el, i),
ctx: this
}) :
props ?? {};
}
/**
* Returns a component name to render the specified item
*
* @param el
* @param i
*/
protected getItemComponentName(el: this['Item'], i: number): string {
const
{item, option} = this;
if (option != null) {
deprecate({
name: 'option',
type: 'property',
renamedTo: 'item'
});
return Object.isFunction(option) ? option(el, i) : option;
}
return Object.isFunction(item) ? item(el, i) : <string>item;
}
/**
* @param el
* @param i
* @deprecated
* @see [[bSlider.getItemKey]]
*/
protected getOptionKey(el: this['Item'], i: number): CanUndef<IterationKey> {
return this.getItemKey(el, i);
}
/**
* @param el
* @param i
* @see [[iItems.getItemKey]]
*/
protected getItemKey(el: this['Item'], i: number): CanUndef<IterationKey> {
return iItems.getItemKey(this, el, i);
}
/**
* Synchronizes the slider state
*/
protected syncState(): void {
const
{view, content} = this.$refs;
if (!view || !content || !this.isSlideMode) {
return;
}
const
{children} = content;
this.viewRect = view.getBoundingClientRect();
this.length = children.length;
this.slideRects = [];
for (let i = 0; i < children.length; i++) {
const
child = <HTMLElement>children[i];
this.slideRects[i] = Object.assign(child.getBoundingClientRect(), {
offsetLeft: child.offsetLeft
});
}
this.performSliderMove();
}
/**
* Synchronizes the slider state (deferred version)
* @emits `syncState()`
*/
protected async syncStateDefer(): Promise<void> {
const
{content} = this;
if (!this.isSlideMode || !content) {
return;
}
try {
await this.async.sleep(50, {label: $$.syncStateDefer, join: true});
this.syncState();
this.emit('syncState');
} catch {}
}
protected override initRemoteData(): CanUndef<this['Items']> {
if (!this.db) {
return;
}
const
val = this.convertDBToComponent<this['Items']>(this.db);
if (Object.isArray(val)) {
if (Object.isArray(this.options)) {
deprecate({
name: 'options',
type: 'property',
renamedTo: 'items'
});
this.options = val;
}
return this.items = val;
}
return this.items;
}
/**
* Initializes the slider mode
*/
protected initMode(): void {
const group = {
group: 'scroll',
label: $$.setScrolling
};
const
{content} = this.$refs;
if (this.isSlideMode) {
this.async.on(document, 'scroll', () => this.scrolling = true, group);
this.initDOMObservers();
} else {
this.async.off(group);
content && iObserveDOM.unobserve(this, content);
}
}
protected override initModEvents(): void {
super.initModEvents();
this.sync.mod('mode', 'mode', String);
this.sync.mod('align', 'align', String);
this.sync.mod('dynamicHeight', 'dynamicHeight', String);
}
/**
* Handler: keeps an initial touch position on the screen
* @param e
*/
protected onStart(e: TouchEvent): void {
this.stopAutoSliding();
this.scrolling = false;
const
touch = e.touches[0],
{clientX, clientY} = touch,
{content} = this;
if (!content) {
return;
}
this.startX = clientX;
this.startY = clientY;
this.syncState();
void this.setMod('swipe', true);
this.startTime = performance.now();
}
/**
* Handler: cancels a scroll if trying to scroll the slider sideways, sets the modified position of the slider
*
* @param e
* @emits `swipeStart()`
*/
protected onMove(e: TouchEvent): void {
if (this.scrolling) {
return;
}
const
{startX, startY, content} = this;
const
touch = e.touches[0],
diffX = startX - touch.clientX,
diffY = startY - touch.clientY;
const isTolerancePassed =
this.isTolerancePassed ||
Math.abs(diffX) > this.swipeToleranceX && Math.abs(diffY) < this.swipeToleranceY;
if (!content || !isTolerancePassed) {
return;
}
if (!this.swiping) {
this.emit('swipeStart');
}
e.preventDefault();
e.stopPropagation();
this.swiping = true;
this.isTolerancePassed = true;
this.diffX = diffX;
this.performSliderMove();
}
/**
* Handler: sets the end position to the slider
* @emits `swipeEnd(dir:` [[SwipeDirection]]`, isChanged: boolean)`
*/
protected onRelease(): void {
if (this.scrolling) {
this.scrolling = false;
return;
}
const {
slideRects,
diffX,
viewRect,
threshold,
startTime,
fastSwipeDelay,
fastSwipeThreshold,
content
} = this;
const
dir = <SlideDirection>Math.sign(diffX);
let
isSwiped = false;
if (!content || Object.size(slideRects) === 0 || !viewRect) {
return;
}
const
timestamp = performance.now(),
passedValue = Number(Math.abs(dir * diffX / viewRect.width).toFixed(2)),
isFastSwiped = timestamp - startTime < fastSwipeDelay && passedValue > fastSwipeThreshold,
isThresholdPassed = passedValue > threshold;
if (isThresholdPassed || isFastSwiped) {
isSwiped = this.moveSlide(dir);
}
this.diffX = 0;
this.performSliderMove();
void this.removeMod('swipe', true);
this.emit('swipeEnd', dir, isSwiped);
this.isTolerancePassed = false;
this.swiping = false;
this.initAutoSliding(Math.max(this.autoSlideInterval, this.autoSlidePostGestureDelay));
}
}
export default bSlider;