UNPKG

@v4fire/client

Version:

V4Fire client core library

916 lines (746 loc) • 19.7 kB
/*! * 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 */ @component() @derive(iObserveDOM) 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. */ @prop({type: String, validator: Object.hasOwnProperty(sliderModes)}) 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. */ @prop(Boolean) 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". */ @prop(Boolean) 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. */ @prop({type: String, validator: Object.hasOwnProperty(alignTypes)}) readonly align: AlignType = 'center'; /** * If true, the first slide will be aligned to the start position (the left bound). */ @prop(Boolean) readonly alignFirstToStart: boolean = true; /** * If true, the last slide will be aligned to the end position (the right bound). */ @prop(Boolean) readonly alignLastToEnd: boolean = true; /** * How much does the shift along the X-axis corresponds to a finger movement */ @prop({type: Number, validator: (v) => Number.isPositiveBetweenZeroAndOne(v)}) readonly deltaX: number = 0.9; /** * The minimum required percentage to scroll the slider to another slide */ @prop({type: Number, validator: (v) => Number.isPositiveBetweenZeroAndOne(v)}) readonly threshold: number = 0.3; /** * The minimum required percentage for the scroll slider to another slide in fast motion on the slider */ @prop({type: Number, validator: (v) => Number.isPositiveBetweenZeroAndOne(v)}) readonly fastSwipeThreshold: number = 0.05; /** * Time (in milliseconds) after which we can assume that there was a quick swipe */ @prop({type: Number, validator: (v) => Number.isNatural(v)}) 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) */ @prop({type: Number, validator: (v) => Number.isNatural(v)}) readonly swipeToleranceX: number = 10; /** * The minimum Y-axis offset threshold at which the slider will be considered to be used (in px) */ @prop({type: Number, validator: (v) => Number.isNatural(v)}) readonly swipeToleranceY: number = 50; /** * The interval (in ms) between auto slide moves. 0 means no auto slide moves. */ @prop({type: Number, validator: (v) => Number.isNonNegative(v)}) 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. */ @prop({type: Number, validator: (v) => Number.isNonNegative(v)}) readonly autoSlidePostGestureDelay: number = 0; /** * @deprecated * @see [[bSlider.items]] */ @prop(Array) readonly optionsProp: iItems['items'] = []; /** @see [[iItems.items]] */ @prop(Array) readonly itemsProp: iItems['items'] = []; /** * @deprecated * @see [[bSlider.item]] */ @prop({type: [String, Function], required: false}) readonly option?: iItems['item']; /** @see [[iItems.item]] */ @prop({type: [String, Function], required: false}) readonly item?: iItems['item']; /** * @deprecated * @see [[bSlider.itemKey]] */ @prop({type: [String, Function], required: false}) readonly optionKey?: iItems['itemKey']; /** @see [[iItems.itemKey]] */ @prop({type: [String, Function], required: false}) readonly itemKey?: iItems['itemKey']; /** * @deprecated * @see [[bSlider.itemProps]] */ @prop({type: [Function, Object], required: false}) readonly optionProps?: iItems['itemProps']; /** @see [[iItems.itemProps]] */ @prop({type: [Function, Object], required: false}) readonly itemProps?: iItems['itemProps']; /** @see [[bSlider.items]] */ @field((o) => o.sync.link()) options!: this['Items']; /** * The number of slides in the slider */ @system() 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]] */ @deprecated({renamedTo: '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]] */ @field((o) => o.sync.link()) protected itemsStore!: iItems['items']; /** @see [[bSlider.current]] */ @system() protected currentStore: number = 0; /** @see [[bSlider.modeProp]] */ @field((o) => o.sync.link((value: Mode) => { if (value === 'slider') { deprecate({ name: 'slider', type: 'property', renamedTo: 'slide' }); return 'slide'; } return value; })) protected mode!: Mode; protected override readonly $refs!: { view?: HTMLElement; content?: HTMLElement; contentWrapper?: HTMLElement; }; /** * X position of the first touch on the slider */ @system() protected startX: number = 0; /** * Y position of the first touch on the slider */ @system() protected startY: number = 0; /** * The difference between a touch position on X axis at the beginning of the swipe and at the end */ @system() protected diffX: number = 0; /** * Is the minimum threshold for starting slide content passed * * @see [[bSlider.swipeToleranceX]] * @see [[bSlider.swipeToleranceY]] */ @system() protected isTolerancePassed: boolean = false; /** * Slide positions */ @system() protected slideRects: SlideRect[] = []; /** * Slider viewport rectangle */ @system() protected viewRect?: DOMRect; /** * Timestamp of a start touch on the slider */ @system() protected startTime: number = 0; /** * True if a user has started scrolling */ @system() protected scrolling: boolean = true; /** * True if a user has started swiping */ @system() protected swiping: boolean = false; /** * True if all animations need to use `requestAnimationFrame` */ @computed({cache: true}) protected get shouldUseRAF(): boolean { return this.browser.is.iOS === false; } /** * True if needed to minimize the amount of non-essential motion used */ @computed({cache: true}) protected get shouldReduceMotion(): boolean { return globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches; } /** @see [[iItems.items]] */ @computed({dependencies: ['itemsStore', 'options']}) 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]] */ @hook('mounted') 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. */ @hook('mounted') @wait('ready') @watch(['db', 'autoSlideInterval', '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]] */ @deprecated({renamedTo: '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 */ @hook('mounted') @wait('loading', {label: $$.syncState}) 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()` */ @watch(':DOMChange') @wait('ready', {label: $$.syncStateDefer}) 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 */ @watch({field: 'mode', immediate: true}) 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;