From 4a57c17e151ecdc101848fa87a8645d163cb8811 Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Tue, 6 Aug 2024 03:53:02 +0200 Subject: [PATCH 01/12] refactor(esl-carousel): move types to the common place, rearrange navigation request and initial update --- .../esl-carousel/core/esl-carousel.events.ts | 4 +-- .../core/esl-carousel.renderer.ts | 34 +++++++++++++------ src/modules/esl-carousel/core/esl-carousel.ts | 20 +++-------- ...sel.nav.types.ts => esl-carousel.types.ts} | 12 ++++++- .../core/nav/esl-carousel.nav.utils.ts | 7 ++-- .../plugin/nav/esl-carousel.nav.mixin.ts | 2 +- .../esl-carousel.default.renderer.ts | 4 +-- .../renderers/esl-carousel.grid.renderer.ts | 8 ++--- .../renderers/esl-carousel.none.renderer.ts | 2 +- .../test/core/esl-carousel.nav.test.ts | 2 +- .../core/esl-carousel.slide.events.test.ts | 6 ++-- 11 files changed, 55 insertions(+), 46 deletions(-) rename src/modules/esl-carousel/core/{nav/esl-carousel.nav.types.ts => esl-carousel.types.ts} (82%) diff --git a/src/modules/esl-carousel/core/esl-carousel.events.ts b/src/modules/esl-carousel/core/esl-carousel.events.ts index bd8bfba1d..361e0daf4 100644 --- a/src/modules/esl-carousel/core/esl-carousel.events.ts +++ b/src/modules/esl-carousel/core/esl-carousel.events.ts @@ -1,5 +1,5 @@ import type {ESLCarousel} from './esl-carousel'; -import type {ESLCarouselDirection, ESLCarouselStaticState} from './nav/esl-carousel.nav.types'; +import type {ESLCarouselDirection, ESLCarouselStaticState} from './esl-carousel.types'; /** {@link ESLCarouselSlideEvent} init object */ export interface ESLCarouselSlideEventInit { @@ -8,7 +8,7 @@ export interface ESLCarouselSlideEventInit { /** A list of indexes of slides that are active after the change */ indexesAfter: number[]; /** Direction of slide animation */ - direction: ESLCarouselDirection | null; + direction?: ESLCarouselDirection; /** Auxiliary request attribute that represents object that initiates slide change */ activator?: any; } diff --git a/src/modules/esl-carousel/core/esl-carousel.renderer.ts b/src/modules/esl-carousel/core/esl-carousel.renderer.ts index 4557f0668..e9d9d494a 100644 --- a/src/modules/esl-carousel/core/esl-carousel.renderer.ts +++ b/src/modules/esl-carousel/core/esl-carousel.renderer.ts @@ -2,10 +2,10 @@ import {memoize} from '../../esl-utils/decorators'; import {isEqual} from '../../esl-utils/misc/object'; import {SyntheticEventTarget} from '../../esl-utils/dom'; import {ESLCarouselSlideEvent} from './esl-carousel.events'; -import {normalize, sequence, indexToDirection} from './nav/esl-carousel.nav.utils'; +import {normalize, sequence, indexToDirection, normalizeIndex} from './nav/esl-carousel.nav.utils'; -import type {ESLCarousel, ESLCarouselActionParams} from './esl-carousel'; -import type {ESLCarouselConfig, ESLCarouselDirection} from './nav/esl-carousel.nav.types'; +import type {ESLCarousel} from './esl-carousel'; +import type {ESLCarouselActionParams, ESLCarouselConfig, ESLCarouselDirection, ESLCarouselNavInfo} from './esl-carousel.types'; import type {ESLCarouselSlideEventInit} from './esl-carousel.events'; export abstract class ESLCarouselRenderer implements ESLCarouselConfig { @@ -91,16 +91,30 @@ export abstract class ESLCarouselRenderer implements ESLCarouselConfig { public onUnbind(): void {} /** Processes drawing of the carousel {@link ESLCarousel}. */ public redraw(): void {} + + /** Normalizes an index before navigation */ + protected normalizeIndex(index: number, params?: ESLCarouselActionParams): number { + return normalizeIndex(index, this); + } + /** Normalizes a direction before navigation */ + protected normalizeDirection(direction: ESLCarouselDirection | undefined, params?: ESLCarouselActionParams): ESLCarouselDirection { + return (this.loop ? params && params.direction : null) || direction || 'next'; + } + /** Processes changing slides */ - public async navigate(index: number, direction: ESLCarouselDirection, {activator}: ESLCarouselActionParams): Promise { - const {activeIndex, activeIndexes} = this.$carousel; + public async navigate(to: ESLCarouselNavInfo, params: ESLCarouselActionParams): Promise { + const index = this.normalizeIndex(to.index, params); + const direction = this.normalizeDirection(to.direction, params); + + const indexesAfter = sequence(index, this.count, this.size); + const indexesBefore = this.$carousel.activeIndexes; - if (activeIndex === index && activeIndexes.length === this.count) return; + if (indexesBefore.toString() === indexesAfter.toString()) return; if (!this.$carousel.dispatchEvent(ESLCarouselSlideEvent.create('BEFORE', { + ...params, direction, - activator, - indexesBefore: activeIndexes, - indexesAfter: sequence(index, this.count, this.size) + indexesBefore, + indexesAfter }))) return; this.setPreActive(index); @@ -113,7 +127,7 @@ export abstract class ESLCarouselRenderer implements ESLCarouselConfig { console.error(e); } - this.setActive(index, {direction, activator}); + this.setActive(index, {direction, ...params}); } /** Pre-processing animation action. */ diff --git a/src/modules/esl-carousel/core/esl-carousel.ts b/src/modules/esl-carousel/core/esl-carousel.ts index cc14a338f..29dab08d1 100644 --- a/src/modules/esl-carousel/core/esl-carousel.ts +++ b/src/modules/esl-carousel/core/esl-carousel.ts @@ -18,21 +18,11 @@ import {ESLCarouselChangeEvent} from './esl-carousel.events'; import type { ESLCarouselState, - ESLCarouselDirection, ESLCarouselSlideTarget, ESLCarouselStaticState, - ESLCarouselConfig -} from './nav/esl-carousel.nav.types'; - -/** {@link ESLCarousel} action params interface */ -export interface ESLCarouselActionParams { - /** Element requester of the change */ - activator?: any; - /** Direction to move to. */ - direction?: ESLCarouselDirection; - // TODO: implement - // noAnimation?: boolean; -} + ESLCarouselConfig, + ESLCarouselActionParams +} from './esl-carousel.types'; /** * ESLCarousel component @@ -301,9 +291,7 @@ export class ESLCarousel extends ESLBaseElement { public goTo(target: HTMLElement | ESLCarouselSlideTarget, params: ESLCarouselActionParams = {}): Promise { if (target instanceof HTMLElement) return this.goTo(this.indexOf(target), params); if (!this.renderer) return Promise.reject(); - const nav = toIndex(target, this.state); - const direction = (this.loop ? params.direction : null) || nav.direction || 'next'; - return this.renderer.navigate(nav.index, direction, params); + return this.renderer.navigate(toIndex(target, this.state), params); } /** @returns slide by index (supports not normalized indexes) */ diff --git a/src/modules/esl-carousel/core/nav/esl-carousel.nav.types.ts b/src/modules/esl-carousel/core/esl-carousel.types.ts similarity index 82% rename from src/modules/esl-carousel/core/nav/esl-carousel.nav.types.ts rename to src/modules/esl-carousel/core/esl-carousel.types.ts index d5d5b22eb..0ab037fae 100644 --- a/src/modules/esl-carousel/core/nav/esl-carousel.nav.types.ts +++ b/src/modules/esl-carousel/core/esl-carousel.types.ts @@ -47,5 +47,15 @@ export type ESLCarouselNavInfo = { /** Target index */ index: number; /** Direction to reach the index */ - direction: ESLCarouselDirection | null; + direction?: ESLCarouselDirection; }; + +/** {@link ESLCarousel} action params interface */ +export interface ESLCarouselActionParams { + /** Element requester of the change */ + activator?: any; + /** Direction to move to. */ + direction?: ESLCarouselDirection; + /** Animation duration in milliseconds. (Pass 0 to disable animation) */ + duration?: number; +} diff --git a/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts b/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts index 6ee3b291f..f3ec2acfb 100644 --- a/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts +++ b/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts @@ -5,7 +5,7 @@ import type { ESLCarouselSlideTarget, ESLCarouselState, ESLCarouselStaticState -} from './esl-carousel.nav.types'; +} from '../esl-carousel.types'; /** @returns normalized slide index in bounds of [0, count] range */ export function normalize(index: number, size: number): number { @@ -66,11 +66,10 @@ export function indexToGroup(index: number, count: number, size: number): number } /** @returns closest direction to move to the passed index */ -export function indexToDirection(index: number, {activeIndex, size, loop}: ESLCarouselState): ESLCarouselDirection | null { +export function indexToDirection(index: number, {activeIndex, size, loop}: ESLCarouselState): ESLCarouselDirection | undefined { if (loop) return calcDirection(activeIndex, index, size); if (activeIndex < index) return 'next'; if (activeIndex > index) return 'prev'; - return null; } /** Splits target string into type and index parts */ @@ -129,7 +128,7 @@ export function toIndex(target: ESLCarouselSlideTarget, cfg: ESLCarouselState): const {type, index} = splitTarget(target); if (type === 'group') return resolveGroupIndex(index, cfg); if (type === 'slide') return resolveSlideIndex(index, cfg); - return {index: cfg.activeIndex, direction: null}; + return {index: cfg.activeIndex}; } /** diff --git a/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.mixin.ts b/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.mixin.ts index dcec05b9a..13289e7ca 100644 --- a/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.mixin.ts +++ b/src/modules/esl-carousel/plugin/nav/esl-carousel.nav.mixin.ts @@ -6,7 +6,7 @@ import {ESLTraversingQuery} from '../../../esl-traversing-query/core'; import {ESLCarouselChangeEvent, ESLCarouselSlideEvent} from '../../core/esl-carousel.events'; import type {ESLCarousel} from '../../core/esl-carousel'; -import type {ESLCarouselSlideTarget} from '../../core/nav/esl-carousel.nav.types'; +import type {ESLCarouselSlideTarget} from '../../core/esl-carousel.types'; /** * ESLCarousel navigation helper to define triggers for carousel navigation. diff --git a/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts b/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts index 3867ac994..ed568370a 100644 --- a/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts +++ b/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts @@ -1,7 +1,7 @@ import {normalize, normalizeIndex} from '../core/nav/esl-carousel.nav.utils'; import {ESLCarouselRenderer} from '../core/esl-carousel.renderer'; -import type {ESLCarouselDirection} from '../core/nav/esl-carousel.nav.types'; +import type {ESLCarouselDirection} from '../core/esl-carousel.types'; /** * Default carousel renderer based on CSS Flexbox stage, order (flex), and stage animated movement via CSS transform. @@ -42,7 +42,7 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer { * Prepare to renderer animation. */ public override onBind(): void { - this.currentIndex = this.$carousel.activeIndex >= 0 ? this.$carousel.activeIndex : 0; + this.currentIndex = this.normalizeIndex(this.$carousel.activeIndex); this.redraw(); } diff --git a/src/modules/esl-carousel/renderers/esl-carousel.grid.renderer.ts b/src/modules/esl-carousel/renderers/esl-carousel.grid.renderer.ts index 55c6cdd30..5b40fc01f 100644 --- a/src/modules/esl-carousel/renderers/esl-carousel.grid.renderer.ts +++ b/src/modules/esl-carousel/renderers/esl-carousel.grid.renderer.ts @@ -2,8 +2,7 @@ import {prop, memoize} from '../../esl-utils/decorators'; import {ESLCarouselRenderer} from '../core/esl-carousel.renderer'; import {ESLDefaultCarouselRenderer} from './esl-carousel.default.renderer'; -import type {ESLCarouselDirection} from '../core/nav/esl-carousel.nav.types'; -import type {ESLCarouselActionParams} from '../core/esl-carousel'; +import type {ESLCarouselActionParams, ESLCarouselDirection} from '../core/esl-carousel.types'; /** * {@link ESLDefaultCarouselRenderer} extension to render slides as a multi-row grid. @@ -79,11 +78,10 @@ export class ESLGridCarouselRenderer extends ESLDefaultCarouselRenderer { } /** - * Processes changing slides * Normalize actual active index to the first slide in the current dimension ('row') */ - public override async navigate(index: number, direction: ESLCarouselDirection, {activator}: ESLCarouselActionParams): Promise { - await super.navigate(index - (index % this.ROWS), direction, {activator}); + protected override normalizeIndex(index: number, params?: ESLCarouselActionParams): number { + return super.normalizeIndex(index - (index % this.ROWS), params); } /** Processes animation. */ diff --git a/src/modules/esl-carousel/renderers/esl-carousel.none.renderer.ts b/src/modules/esl-carousel/renderers/esl-carousel.none.renderer.ts index 1042d6dfe..1bd517ab4 100644 --- a/src/modules/esl-carousel/renderers/esl-carousel.none.renderer.ts +++ b/src/modules/esl-carousel/renderers/esl-carousel.none.renderer.ts @@ -1,7 +1,7 @@ import {ESLCarouselRenderer} from '../core/esl-carousel.renderer'; import type {ESLCarousel} from '../core/esl-carousel'; -import type {ESLCarouselConfig, ESLCarouselDirection} from '../core/nav/esl-carousel.nav.types'; +import type {ESLCarouselConfig, ESLCarouselDirection} from '../core/esl-carousel.types'; /** * None effect carousel renderer. Does not provide any animation, transition. Does not limit slide stage. diff --git a/src/modules/esl-carousel/test/core/esl-carousel.nav.test.ts b/src/modules/esl-carousel/test/core/esl-carousel.nav.test.ts index 2d73f8656..395779600 100644 --- a/src/modules/esl-carousel/test/core/esl-carousel.nav.test.ts +++ b/src/modules/esl-carousel/test/core/esl-carousel.nav.test.ts @@ -10,7 +10,7 @@ import type { ESLCarouselDirection, ESLCarouselSlideTarget, ESLCarouselState -} from '../../core/nav/esl-carousel.nav.types'; +} from '../../core/esl-carousel.types'; describe('ESLCarousel: Nav Utils', () => { describe('normalize', () => { diff --git a/src/modules/esl-carousel/test/core/esl-carousel.slide.events.test.ts b/src/modules/esl-carousel/test/core/esl-carousel.slide.events.test.ts index 1d00fc8f1..394cb1f1f 100644 --- a/src/modules/esl-carousel/test/core/esl-carousel.slide.events.test.ts +++ b/src/modules/esl-carousel/test/core/esl-carousel.slide.events.test.ts @@ -26,7 +26,7 @@ describe('ESLCarouselRenderer: Slide change events created correctly', () => { }); test('ESLCarouselSlideEvent: Initial slide triggered correct events', async () => { - const request = $carousel.renderer.navigate(0, 'next', {activator: 'user'}); + const request = $carousel.renderer.navigate({index: 0, direction: 'next'}, {activator: 'user'}); expect(beforeEventTrap).toHaveBeenCalledTimes(1); expect(afterEventTrap).toHaveBeenCalledTimes(0); expect(beforeEventTrap).toHaveBeenLastCalledWith(expect.objectContaining({ @@ -47,7 +47,7 @@ describe('ESLCarouselRenderer: Slide change events created correctly', () => { }); test('ESLCarouselSlideEvent: correct events triggered in the middle state', async () => { - const request = $carousel.renderer.navigate(1, 'next', {activator: 'user'}); + const request = $carousel.renderer.navigate({index: 1, direction: 'next'}, {activator: 'user'}); expect(beforeEventTrap).toHaveBeenCalledTimes(1); expect(afterEventTrap).toHaveBeenCalledTimes(0); expect(beforeEventTrap).toHaveBeenLastCalledWith(expect.objectContaining({ @@ -70,7 +70,7 @@ describe('ESLCarouselRenderer: Slide change events created correctly', () => { }); test('ESLCarouselSlideEvent: Last slide triggered correct events', async () => { - const request = $carousel.renderer.navigate(2, 'next', {activator: 'user'}); + const request = $carousel.renderer.navigate({index: 2, direction: 'next'}, {activator: 'user'}); expect(beforeEventTrap).toHaveBeenCalledTimes(1); expect(afterEventTrap).toHaveBeenCalledTimes(0); expect(beforeEventTrap).toHaveBeenLastCalledWith(expect.objectContaining({ From 2157e6abfcfe0714fe522932985def1e7d202fe4 Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Tue, 6 Aug 2024 03:56:27 +0200 Subject: [PATCH 02/12] fix(esl-carousel): incomplete carousel should be normalized with non loop constraints --- src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts b/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts index f3ec2acfb..f3ce9e656 100644 --- a/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts +++ b/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts @@ -14,7 +14,7 @@ export function normalize(index: number, size: number): number { /** @returns normalize first slide index according to the carousel mode */ export function normalizeIndex(index: number, {size, count, loop}: ESLCarouselStaticState): number { - return loop ? normalize(index, size) : Math.max(0, Math.min(size - count, index)); + return loop && count < size ? normalize(index, size) : Math.max(0, Math.min(size - count, index)); } /** @returns normalized sequence of slides starting from the current index */ From c9bd23dda644d5a982154f0e582960280e819adc Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Fri, 16 Aug 2024 01:28:51 +0200 Subject: [PATCH 03/12] refactor(esl-carousel): reimplement actions api, add step duration --- .../core/esl-carousel.renderer.ts | 41 +++++++------------ src/modules/esl-carousel/core/esl-carousel.ts | 22 +++++++++- .../esl-carousel/core/esl-carousel.types.ts | 4 +- .../plugin/touch/esl-carousel.touch.mixin.ts | 6 +-- .../esl-carousel.default.renderer.ts | 38 +++++++++-------- .../renderers/esl-carousel.grid.renderer.ts | 11 +---- .../renderers/esl-carousel.none.renderer.ts | 6 +-- .../core/esl-carousel.slide.events.test.ts | 6 +-- 8 files changed, 68 insertions(+), 66 deletions(-) diff --git a/src/modules/esl-carousel/core/esl-carousel.renderer.ts b/src/modules/esl-carousel/core/esl-carousel.renderer.ts index e9d9d494a..f298136cd 100644 --- a/src/modules/esl-carousel/core/esl-carousel.renderer.ts +++ b/src/modules/esl-carousel/core/esl-carousel.renderer.ts @@ -108,21 +108,18 @@ export abstract class ESLCarouselRenderer implements ESLCarouselConfig { const indexesAfter = sequence(index, this.count, this.size); const indexesBefore = this.$carousel.activeIndexes; - if (indexesBefore.toString() === indexesAfter.toString()) return; - if (!this.$carousel.dispatchEvent(ESLCarouselSlideEvent.create('BEFORE', { - ...params, - direction, - indexesBefore, - indexesAfter - }))) return; + + const details = {...params, direction, indexesBefore, indexesAfter}; + if (!this.$carousel.dispatchEvent(ESLCarouselSlideEvent.create('BEFORE', details))) return; + // this.$carousel.dispatchEvent(ESLCarouselSlideEvent.create('CHANGE', details)); this.setPreActive(index); try { - await this.onBeforeAnimate(index, direction); - await this.onAnimate(index, direction); - await this.onAfterAnimate(index, direction); + await this.onBeforeAnimate(index, direction, params); + await this.onAnimate(index, direction, params); + await this.onAfterAnimate(index, direction, params); } catch (e: unknown) { console.error(e); } @@ -131,24 +128,16 @@ export abstract class ESLCarouselRenderer implements ESLCarouselConfig { } /** Pre-processing animation action. */ - public async onBeforeAnimate(index?: number, direction?: ESLCarouselDirection): Promise {} + public async onBeforeAnimate(index: number, direction: ESLCarouselDirection, params: ESLCarouselActionParams): Promise {} /** Processes animation. */ - public abstract onAnimate(index: number, direction: ESLCarouselDirection): Promise; + public abstract onAnimate(index: number, direction: ESLCarouselDirection, params: ESLCarouselActionParams): Promise; /** Post-processing animation action. */ - public async onAfterAnimate(index: number, direction: ESLCarouselDirection): Promise {} - - /** - * Moves slide by the passed offset in px. - * @param offset - offset in px - * @param from - start index (default: current active index) - */ - public abstract move(offset: number, from?: number): void; - /** - * Normalizes move offset to the "nearest stable" slide position. - * @param offset - offset in px - * @param from - start index (default: current active index) - */ - public abstract commit(offset?: number, from?: number): void; + public async onAfterAnimate(index: number, direction: ESLCarouselDirection, params: ESLCarouselActionParams): Promise {} + + /** Moves slide by the passed offset in px */ + public abstract move(offset: number, from: number, params: ESLCarouselActionParams): void; + /** Normalizes move offset to the "nearest stable" slide position */ + public abstract commit(offset: number, from: number, params: ESLCarouselActionParams): Promise; /** Sets active slides from passed index **/ public setActive(current: number, event?: Partial): void { diff --git a/src/modules/esl-carousel/core/esl-carousel.ts b/src/modules/esl-carousel/core/esl-carousel.ts index 29dab08d1..50fcb5127 100644 --- a/src/modules/esl-carousel/core/esl-carousel.ts +++ b/src/modules/esl-carousel/core/esl-carousel.ts @@ -159,6 +159,7 @@ export class ESLCarousel extends ESLBaseElement { this.renderer.unbind(); memoize.clear(this, 'renderer'); this.renderer.bind(); + this.updateStateMarkers(); this.dispatchEvent(ESLCarouselChangeEvent.create({initial, added, removed, config, oldConfig})); } @@ -288,10 +289,27 @@ export class ESLCarousel extends ESLBaseElement { } /** Goes to the target according to passed params */ - public goTo(target: HTMLElement | ESLCarouselSlideTarget, params: ESLCarouselActionParams = {}): Promise { + public goTo(target: HTMLElement | ESLCarouselSlideTarget, params: Partial = {}): Promise { if (target instanceof HTMLElement) return this.goTo(this.indexOf(target), params); if (!this.renderer) return Promise.reject(); - return this.renderer.navigate(toIndex(target, this.state), params); + return this.renderer.navigate(toIndex(target, this.state), this.mergeParams(params)); + } + + /** Move slides by the passed offset */ + public move(offset: number, from: number = this.activeIndex, params: Partial = {}): void { + if (!this.renderer) return; + this.renderer.move(offset, from, this.mergeParams(params)); + } + + /** Commit slides to the nearest stable position */ + public commit(offset: number, from: number = this.activeIndex, params: Partial = {}): Promise { + if (!this.renderer) return Promise.reject(); + return this.renderer.commit(offset, from, this.mergeParams(params)); + } + + /** Merge request params with default params */ + protected mergeParams(params: Partial): ESLCarouselActionParams { + return {stepDuration: 250, ...params}; } /** @returns slide by index (supports not normalized indexes) */ diff --git a/src/modules/esl-carousel/core/esl-carousel.types.ts b/src/modules/esl-carousel/core/esl-carousel.types.ts index 0ab037fae..5c7e1d108 100644 --- a/src/modules/esl-carousel/core/esl-carousel.types.ts +++ b/src/modules/esl-carousel/core/esl-carousel.types.ts @@ -56,6 +56,6 @@ export interface ESLCarouselActionParams { activator?: any; /** Direction to move to. */ direction?: ESLCarouselDirection; - /** Animation duration in milliseconds. (Pass 0 to disable animation) */ - duration?: number; + /** Duration of a single slide transition in milliseconds. (Set to 0 to disable animation) */ + stepDuration: number; } diff --git a/src/modules/esl-carousel/plugin/touch/esl-carousel.touch.mixin.ts b/src/modules/esl-carousel/plugin/touch/esl-carousel.touch.mixin.ts index f8d399c48..f35d235e1 100644 --- a/src/modules/esl-carousel/plugin/touch/esl-carousel.touch.mixin.ts +++ b/src/modules/esl-carousel/plugin/touch/esl-carousel.touch.mixin.ts @@ -133,7 +133,7 @@ export class ESLCarouselTouchMixin extends ESLCarouselPlugin { + /** Animate scene offset to index */ + protected async animateTo(index: number, duration = 250): Promise { + this.currentIndex = this.normalizeIndex(index); + const offset = -this.getOffset(this.currentIndex); this.$carousel.$$attr('animating', true); await this.$area.animate({ transform: [`translate3d(${this.vertical ? `0px, ${offset}px` : `${offset}px, 0px`}, 0px)`] - }, {duration: 250, easing: 'linear'}).finished; + }, {duration, easing: 'linear'}).finished; this.$carousel.$$attr('animating', false); } /** Pre-processing animation action. */ - public override async onBeforeAnimate(): Promise { + public override async onBeforeAnimate(nextIndex: number, direction: ESLCarouselDirection, params: ESLCarouselActionParams): Promise { if (this.$carousel.hasAttribute('animating')) throw new Error('[ESL] Carousel: already animating'); this.$carousel.$$attr('active', true); } /** Processes animation. */ - public async onAnimate(nextIndex: number, direction: ESLCarouselDirection): Promise { + public async onAnimate(nextIndex: number, direction: ESLCarouselDirection, params: ESLCarouselActionParams): Promise { const {activeIndex, $slidesArea} = this.$carousel; this.currentIndex = activeIndex; if (!$slidesArea) return; - while (this.currentIndex !== nextIndex) await this.onStepAnimate(direction === 'next' ? 1 : -1); + const sign = direction === 'next' ? 1 : -1; + const distance = normalize((nextIndex - activeIndex) * sign, this.size); + const speed = Math.max(1, distance / this.count); + while (this.currentIndex !== nextIndex) { + await this.onStepAnimate(sign * this.INDEX_MOVE_MULTIPLIER, params.stepDuration * speed); + } } /** Post-processing animation action. */ - public override async onAfterAnimate(): Promise { + public override async onAfterAnimate(nextIndex: number, direction: ESLCarouselDirection, params: ESLCarouselActionParams): Promise { // Make sure we end up in a defined state on transition end this.reorder(); this.setTransformOffset(-this.getOffset(this.currentIndex)); @@ -109,21 +116,19 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer { } /** Makes pre-processing the transition animation of one slide. */ - protected async onStepAnimate(indexOffset: number): Promise { + protected async onStepAnimate(indexOffset: number, duration: number): Promise { const index = normalize(this.currentIndex + indexOffset, this.size); // Make sure there is a slide in required direction this.reorder(indexOffset < 0); - const offsetFrom = -this.getOffset(this.currentIndex); this.setTransformOffset(offsetFrom); - this.currentIndex = index; - await this.animateTransformOffset(); + await this.animateTo(index, duration); } /** Handles the slides transition. */ - public move(offset: number, from = this.$carousel.activeIndex): void { + public move(offset: number, from: number, params: ESLCarouselActionParams): void { this.$carousel.toggleAttribute('active', true); const slideSize = this.slideSize + this.gap; @@ -148,7 +153,7 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer { /** Ends current transition and make permanent all changes performed in the transition. */ // eslint-disable-next-line sonarjs/cognitive-complexity - public async commit(offset: number, from = this.$carousel.activeIndex): Promise { + public async commit(offset: number, from: number, params: ESLCarouselActionParams): Promise { const slideSize = this.slideSize + this.gap; const amount = Math.abs(offset) / slideSize; @@ -156,8 +161,7 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer { const count = (amount - Math.floor(amount)) > tolerance ? Math.ceil(amount) : Math.floor(amount); const index = from + count * this.INDEX_MOVE_MULTIPLIER * (offset < 0 ? 1 : -1); - this.currentIndex = normalizeIndex(index, this); - await this.animateTransformOffset(); + await this.animateTo(index); this.reorder(); this.setTransformOffset(-this.getOffset(this.currentIndex)); diff --git a/src/modules/esl-carousel/renderers/esl-carousel.grid.renderer.ts b/src/modules/esl-carousel/renderers/esl-carousel.grid.renderer.ts index 5b40fc01f..51b70c086 100644 --- a/src/modules/esl-carousel/renderers/esl-carousel.grid.renderer.ts +++ b/src/modules/esl-carousel/renderers/esl-carousel.grid.renderer.ts @@ -2,7 +2,7 @@ import {prop, memoize} from '../../esl-utils/decorators'; import {ESLCarouselRenderer} from '../core/esl-carousel.renderer'; import {ESLDefaultCarouselRenderer} from './esl-carousel.default.renderer'; -import type {ESLCarouselActionParams, ESLCarouselDirection} from '../core/esl-carousel.types'; +import type {ESLCarouselActionParams} from '../core/esl-carousel.types'; /** * {@link ESLDefaultCarouselRenderer} extension to render slides as a multi-row grid. @@ -84,15 +84,6 @@ export class ESLGridCarouselRenderer extends ESLDefaultCarouselRenderer { return super.normalizeIndex(index - (index % this.ROWS), params); } - /** Processes animation. */ - public override async onAnimate(nextIndex: number, direction: ESLCarouselDirection): Promise { - const {activeIndex, $slidesArea} = this.$carousel; - this.currentIndex = activeIndex; - if (!$slidesArea) return; - const step = this.ROWS * (direction === 'next' ? 1 : -1); - while (this.currentIndex !== nextIndex) await this.onStepAnimate(step); - } - /** * @returns count of slides to be rendered (reserved) before the first slide does not include fake slides */ diff --git a/src/modules/esl-carousel/renderers/esl-carousel.none.renderer.ts b/src/modules/esl-carousel/renderers/esl-carousel.none.renderer.ts index 1bd517ab4..ac09e3c51 100644 --- a/src/modules/esl-carousel/renderers/esl-carousel.none.renderer.ts +++ b/src/modules/esl-carousel/renderers/esl-carousel.none.renderer.ts @@ -1,7 +1,7 @@ import {ESLCarouselRenderer} from '../core/esl-carousel.renderer'; import type {ESLCarousel} from '../core/esl-carousel'; -import type {ESLCarouselConfig, ESLCarouselDirection} from '../core/esl-carousel.types'; +import type {ESLCarouselActionParams, ESLCarouselConfig, ESLCarouselDirection} from '../core/esl-carousel.types'; /** * None effect carousel renderer. Does not provide any animation, transition. Does not limit slide stage. @@ -35,6 +35,6 @@ export class ESLNoneCarouselRenderer extends ESLCarouselRenderer { public async onAnimate(nextIndex: number, direction: ESLCarouselDirection): Promise {} /* Handles the slide move actions */ - public move(offset: number, from?: number): void {} - public commit(offset: number, from?: number): void {} + public move(offset: number, from: number, params: ESLCarouselActionParams): void {} + public async commit(offset: number, from: number, params: ESLCarouselActionParams): Promise {} } diff --git a/src/modules/esl-carousel/test/core/esl-carousel.slide.events.test.ts b/src/modules/esl-carousel/test/core/esl-carousel.slide.events.test.ts index 394cb1f1f..1035ac129 100644 --- a/src/modules/esl-carousel/test/core/esl-carousel.slide.events.test.ts +++ b/src/modules/esl-carousel/test/core/esl-carousel.slide.events.test.ts @@ -26,7 +26,7 @@ describe('ESLCarouselRenderer: Slide change events created correctly', () => { }); test('ESLCarouselSlideEvent: Initial slide triggered correct events', async () => { - const request = $carousel.renderer.navigate({index: 0, direction: 'next'}, {activator: 'user'}); + const request = $carousel.renderer.navigate({index: 0, direction: 'next'}, {activator: 'user', stepDuration: 0}); expect(beforeEventTrap).toHaveBeenCalledTimes(1); expect(afterEventTrap).toHaveBeenCalledTimes(0); expect(beforeEventTrap).toHaveBeenLastCalledWith(expect.objectContaining({ @@ -47,7 +47,7 @@ describe('ESLCarouselRenderer: Slide change events created correctly', () => { }); test('ESLCarouselSlideEvent: correct events triggered in the middle state', async () => { - const request = $carousel.renderer.navigate({index: 1, direction: 'next'}, {activator: 'user'}); + const request = $carousel.renderer.navigate({index: 1, direction: 'next'}, {activator: 'user', stepDuration: 0}); expect(beforeEventTrap).toHaveBeenCalledTimes(1); expect(afterEventTrap).toHaveBeenCalledTimes(0); expect(beforeEventTrap).toHaveBeenLastCalledWith(expect.objectContaining({ @@ -70,7 +70,7 @@ describe('ESLCarouselRenderer: Slide change events created correctly', () => { }); test('ESLCarouselSlideEvent: Last slide triggered correct events', async () => { - const request = $carousel.renderer.navigate({index: 2, direction: 'next'}, {activator: 'user'}); + const request = $carousel.renderer.navigate({index: 2, direction: 'next'}, {activator: 'user', stepDuration: 0}); expect(beforeEventTrap).toHaveBeenCalledTimes(1); expect(afterEventTrap).toHaveBeenCalledTimes(0); expect(beforeEventTrap).toHaveBeenLastCalledWith(expect.objectContaining({ From b587f3b70305d7ce15e06603c99b9a632a08a7a2 Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Fri, 16 Aug 2024 02:54:40 +0200 Subject: [PATCH 04/12] refactor(esl-carousel): replace ESLCarouselDirection with enum/number representation --- .../core/esl-carousel.renderer.ts | 7 +- .../esl-carousel/core/esl-carousel.types.ts | 7 +- .../core/nav/esl-carousel.nav.utils.ts | 36 +++++----- .../esl-carousel.default.renderer.ts | 13 ++-- .../test/core/esl-carousel.nav.test.ts | 67 ++++++++++++++----- .../core/esl-carousel.slide.events.test.ts | 19 +++--- 6 files changed, 93 insertions(+), 56 deletions(-) diff --git a/src/modules/esl-carousel/core/esl-carousel.renderer.ts b/src/modules/esl-carousel/core/esl-carousel.renderer.ts index f298136cd..98a85b201 100644 --- a/src/modules/esl-carousel/core/esl-carousel.renderer.ts +++ b/src/modules/esl-carousel/core/esl-carousel.renderer.ts @@ -1,12 +1,13 @@ import {memoize} from '../../esl-utils/decorators'; import {isEqual} from '../../esl-utils/misc/object'; import {SyntheticEventTarget} from '../../esl-utils/dom'; +import {ESLCarouselDirection} from './esl-carousel.types'; import {ESLCarouselSlideEvent} from './esl-carousel.events'; -import {normalize, sequence, indexToDirection, normalizeIndex} from './nav/esl-carousel.nav.utils'; +import {indexToDirection, normalize, normalizeIndex, sequence} from './nav/esl-carousel.nav.utils'; import type {ESLCarousel} from './esl-carousel'; -import type {ESLCarouselActionParams, ESLCarouselConfig, ESLCarouselDirection, ESLCarouselNavInfo} from './esl-carousel.types'; import type {ESLCarouselSlideEventInit} from './esl-carousel.events'; +import type {ESLCarouselActionParams, ESLCarouselConfig, ESLCarouselNavInfo} from './esl-carousel.types'; export abstract class ESLCarouselRenderer implements ESLCarouselConfig { public static is: string; @@ -98,7 +99,7 @@ export abstract class ESLCarouselRenderer implements ESLCarouselConfig { } /** Normalizes a direction before navigation */ protected normalizeDirection(direction: ESLCarouselDirection | undefined, params?: ESLCarouselActionParams): ESLCarouselDirection { - return (this.loop ? params && params.direction : null) || direction || 'next'; + return (this.loop ? params && params.direction : null) || direction || ESLCarouselDirection.NEXT; } /** Processes changing slides */ diff --git a/src/modules/esl-carousel/core/esl-carousel.types.ts b/src/modules/esl-carousel/core/esl-carousel.types.ts index 5c7e1d108..95775dfce 100644 --- a/src/modules/esl-carousel/core/esl-carousel.types.ts +++ b/src/modules/esl-carousel/core/esl-carousel.types.ts @@ -1,4 +1,9 @@ -export type ESLCarouselDirection = 'next' | 'prev'; +/** Direction enum, can be used in calculation directly */ +export enum ESLCarouselDirection { + NEXT = 1, + NONE = 0, + PREV = -1 +} export type ESLCarouselNavIndex = number | `${number}` | `+${number}` | `-${number}` | ESLCarouselDirection; diff --git a/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts b/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts index f3ce9e656..e384a7bc6 100644 --- a/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts +++ b/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts @@ -1,5 +1,5 @@ +import {ESLCarouselDirection} from '../esl-carousel.types'; import type { - ESLCarouselDirection, ESLCarouselNavIndex, ESLCarouselNavInfo, ESLCarouselSlideTarget, @@ -7,6 +7,9 @@ import type { ESLCarouselStaticState } from '../esl-carousel.types'; +/** @returns sign of the value */ +export const sign = (value: number): -1 | 1 | 0 => value > 0 ? 1 : value < 0 ? -1 : 0; + /** @returns normalized slide index in bounds of [0, count] range */ export function normalize(index: number, size: number): number { return (size + (index % size)) % size; @@ -28,19 +31,13 @@ export function sequence(current: number, count: number, size: number): number[] /** Gets count of slides between active and passed considering given direction. */ export function getDistance(from: number, direction: ESLCarouselDirection, {activeIndex, size}: ESLCarouselState): number { - if (direction === 'prev') return normalize(activeIndex - from, size); - if (direction === 'next') return normalize(from - activeIndex, size); - return 0; + return normalize(direction * (from - activeIndex), size); } -/** @returns closest direction to move from slide `from` to slide `to` */ +/** @returns closest direction to move from the slide `from` to slide `to` */ function calcDirection(from: number, to: number, size: number): ESLCarouselDirection { const abs = Math.abs(from - to) % size; - if (to > from) { - return abs > size - abs ? 'prev' : 'next'; - } else { - return abs > size - abs ? 'next' : 'prev'; - } + return sign(to - from) * sign(size / 2 - abs); } /** @returns normalized numeric index from group index */ @@ -67,9 +64,7 @@ export function indexToGroup(index: number, count: number, size: number): number /** @returns closest direction to move to the passed index */ export function indexToDirection(index: number, {activeIndex, size, loop}: ESLCarouselState): ESLCarouselDirection | undefined { - if (loop) return calcDirection(activeIndex, index, size); - if (activeIndex < index) return 'next'; - if (activeIndex > index) return 'prev'; + return loop ? calcDirection(activeIndex, index, size) : sign(index - activeIndex); } /** Splits target string into type and index parts */ @@ -86,10 +81,10 @@ function splitTarget(target: string): {index: string, type: string} { function parseIndex(index: string | ESLCarouselNavIndex): {value: number, isRelative: boolean, direction?: ESLCarouselDirection} { if (typeof index === 'number') return {value: index, isRelative: false}; index = index.trim(); - if (index === 'next') return {value: 1, isRelative: true, direction: 'next'}; - if (index === 'prev') return {value: -1, isRelative: true, direction: 'prev'}; - if (index[0] === '+') return {value: +index, isRelative: true, direction: 'next'}; - if (index[0] === '-') return {value: +index, isRelative: true, direction: 'prev'}; + if (index === 'next') return {value: 1, isRelative: true, direction: ESLCarouselDirection.NEXT}; + if (index === 'prev') return {value: -1, isRelative: true, direction: ESLCarouselDirection.PREV}; + if (index[0] === '+') return {value: +index, isRelative: true, direction: ESLCarouselDirection.NEXT}; + if (index[0] === '-') return {value: +index, isRelative: true, direction: ESLCarouselDirection.PREV}; return {value: +index, isRelative: false}; } @@ -110,10 +105,10 @@ function resolveGroupIndex(indexStr: string | ESLCarouselNavIndex, cfg: ESLCarou } // TODO: extend navigation boundaries if (value === -1 && cfg.activeIndex < cfg.count && cfg.activeIndex > 0) { - return {index: 0, direction: direction || 'prev'}; + return {index: 0, direction: direction || ESLCarouselDirection.PREV}; } if (value === 1 && normalize(cfg.activeIndex + cfg.count, cfg.size) > cfg.size - cfg.count) { - return {index: cfg.size - cfg.count, direction: direction || 'next'}; + return {index: cfg.size - cfg.count, direction: direction || ESLCarouselDirection.NEXT}; } const index = normalize(cfg.activeIndex + value * cfg.count, cfg.size); return {index, direction: direction || indexToDirection(index, cfg)}; @@ -138,7 +133,6 @@ export function toIndex(target: ESLCarouselSlideTarget, cfg: ESLCarouselState): export function canNavigate(target: ESLCarouselSlideTarget, cfg: ESLCarouselState): boolean { if (cfg.size <= cfg.count) return false; const {direction, index} = toIndex(target, cfg); - if (!cfg.loop && index > cfg.activeIndex && direction === 'prev') return false; - if (!cfg.loop && index < cfg.activeIndex && direction === 'next') return false; + if (!cfg.loop && direction && index < direction * cfg.activeIndex) return false; return !!direction && index !== cfg.activeIndex; } diff --git a/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts b/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts index b4055f03b..4b6e056aa 100644 --- a/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts +++ b/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts @@ -1,7 +1,7 @@ -import {normalize} from '../core/nav/esl-carousel.nav.utils'; +import {normalize, sign} from '../core/nav/esl-carousel.nav.utils'; import {ESLCarouselRenderer} from '../core/esl-carousel.renderer'; -import type {ESLCarouselActionParams, ESLCarouselDirection} from '../core/esl-carousel.types'; +import type {ESLCarouselDirection, ESLCarouselActionParams} from '../core/esl-carousel.types'; /** * Default carousel renderer based on CSS Flexbox stage, order (flex), and stage animated movement via CSS transform. @@ -99,11 +99,10 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer { const {activeIndex, $slidesArea} = this.$carousel; this.currentIndex = activeIndex; if (!$slidesArea) return; - const sign = direction === 'next' ? 1 : -1; - const distance = normalize((nextIndex - activeIndex) * sign, this.size); + const distance = normalize((nextIndex - activeIndex) * direction, this.size); const speed = Math.max(1, distance / this.count); while (this.currentIndex !== nextIndex) { - await this.onStepAnimate(sign * this.INDEX_MOVE_MULTIPLIER, params.stepDuration * speed); + await this.onStepAnimate(direction * this.INDEX_MOVE_MULTIPLIER, params.stepDuration * speed); } } @@ -147,7 +146,7 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer { this.setTransformOffset(-stageOffset); if (this.currentIndex !== this.$carousel.activeIndex) { - this.setActive(this.currentIndex, {direction: offset < 0 ? 'next' : 'prev'}); + this.setActive(this.currentIndex, {direction: sign(-offset)}); } } @@ -168,7 +167,7 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer { this.$carousel.$$attr('active', false); if (this.currentIndex !== this.$carousel.activeIndex) { - this.setActive(this.currentIndex, {direction: offset < 0 ? 'next' : 'prev'}); + this.setActive(this.currentIndex, {direction: sign(-offset)}); } } diff --git a/src/modules/esl-carousel/test/core/esl-carousel.nav.test.ts b/src/modules/esl-carousel/test/core/esl-carousel.nav.test.ts index 395779600..0e21ed8be 100644 --- a/src/modules/esl-carousel/test/core/esl-carousel.nav.test.ts +++ b/src/modules/esl-carousel/test/core/esl-carousel.nav.test.ts @@ -3,14 +3,11 @@ import { groupToIndex, indexToGroup, indexToDirection, - toIndex + toIndex, canNavigate } from '../../core/nav/esl-carousel.nav.utils'; +import {ESLCarouselDirection} from '../../core/esl-carousel.types'; -import type { - ESLCarouselDirection, - ESLCarouselSlideTarget, - ESLCarouselState -} from '../../core/esl-carousel.types'; +import type {ESLCarouselSlideTarget, ESLCarouselState} from '../../core/esl-carousel.types'; describe('ESLCarousel: Nav Utils', () => { describe('normalize', () => { @@ -146,16 +143,16 @@ describe('ESLCarousel: Nav Utils', () => { describe('indexToDirection', () => { test.each([ // [to, {activeIndex, size, loop}, result] - [1, {activeIndex: 0, size: 5, loop: false}, 'next'], - [4, {activeIndex: 0, size: 5, loop: false}, 'next'], - [4, {activeIndex: 3, size: 5, loop: false}, 'next'], - [0, {activeIndex: 1, size: 5, loop: false}, 'prev'], - [0, {activeIndex: 4, size: 5, loop: false}, 'prev'], + [1, {activeIndex: 0, size: 5, loop: false}, ESLCarouselDirection.NEXT], + [4, {activeIndex: 0, size: 5, loop: false}, ESLCarouselDirection.NEXT], + [4, {activeIndex: 3, size: 5, loop: false}, ESLCarouselDirection.NEXT], + [0, {activeIndex: 1, size: 5, loop: false}, ESLCarouselDirection.PREV], + [0, {activeIndex: 4, size: 5, loop: false}, ESLCarouselDirection.PREV], - [1, {activeIndex: 0, size: 5, loop: true}, 'next'], - [3, {activeIndex: 1, size: 5, loop: true}, 'next'], - [2, {activeIndex: 3, size: 5, loop: true}, 'prev'], - [4, {activeIndex: 1, size: 5, loop: true}, 'prev'] + [1, {activeIndex: 0, size: 5, loop: true}, ESLCarouselDirection.NEXT], + [3, {activeIndex: 1, size: 5, loop: true}, ESLCarouselDirection.NEXT], + [2, {activeIndex: 3, size: 5, loop: true}, ESLCarouselDirection.PREV], + [4, {activeIndex: 1, size: 5, loop: true}, ESLCarouselDirection.PREV] ])( '(to = %d, state = %p) => %s', (to: number, state: ESLCarouselState, result: ESLCarouselDirection) => expect(indexToDirection(to, state)).toBe(result) @@ -283,4 +280,44 @@ describe('ESLCarousel: Nav Utils', () => { ); }); }); + + describe('canNavigate', () => { + test.each([ + // Loop for complete + ['next', {activeIndex: 0, size: 5, count: 1, loop: true}], + ['prev', {activeIndex: 0, size: 5, count: 1, loop: true}], + ['next', {activeIndex: 4, size: 5, count: 1, loop: true}], + // Non loop case with free space + ['next', {activeIndex: 0, size: 5, count: 1, loop: false}], + ['prev', {activeIndex: 4, size: 5, count: 1, loop: false}], + ['next', {activeIndex: 2, size: 5, count: 1, loop: false}], + ['prev', {activeIndex: 2, size: 5, count: 1, loop: false}] + ])( + 'Target %s is allowed for %p configuration', + (target: ESLCarouselSlideTarget, cfg: ESLCarouselState) => expect(canNavigate(target, cfg)).toBe(true) + ); + + test.each([ + // Non loop case with no free space + ['next', {activeIndex: 4, size: 5, count: 1, loop: false}], + ['prev', {activeIndex: 0, size: 5, count: 1, loop: false}], + // Incomplete cases + ['next', {activeIndex: 0, size: 1, count: 1, loop: false}], + ['prev', {activeIndex: 0, size: 1, count: 1, loop: false}], + ['next', {activeIndex: 0, size: 1, count: 1, loop: true}], + ['prev', {activeIndex: 0, size: 1, count: 1, loop: true}], + ['next', {activeIndex: 0, size: 2, count: 2, loop: true}], + ['prev', {activeIndex: 0, size: 2, count: 2, loop: true}], + ['next', {activeIndex: 0, size: 1, count: 2, loop: true}], + ['prev', {activeIndex: 0, size: 1, count: 2, loop: true}], + // Same slide + ['0', {activeIndex: 0, size: 5, count: 1, loop: false}], + ['0', {activeIndex: 0, size: 5, count: 1, loop: true}], + ['1', {activeIndex: 1, size: 5, count: 2, loop: false}], + ['1', {activeIndex: 1, size: 5, count: 2, loop: true}] + ])( + 'Target %s is not allowed for %p configuration', + (target: ESLCarouselSlideTarget, cfg: ESLCarouselState) => expect(canNavigate(target, cfg)).toBe(false) + ); + }); }); diff --git a/src/modules/esl-carousel/test/core/esl-carousel.slide.events.test.ts b/src/modules/esl-carousel/test/core/esl-carousel.slide.events.test.ts index 1035ac129..32192f0dd 100644 --- a/src/modules/esl-carousel/test/core/esl-carousel.slide.events.test.ts +++ b/src/modules/esl-carousel/test/core/esl-carousel.slide.events.test.ts @@ -1,5 +1,6 @@ import {createDummyCarousel} from '../common/esl-carousel.dummy'; import {ESLCarouselSlideEvent} from '../../core/esl-carousel.events'; +import {ESLCarouselDirection} from '../../core/esl-carousel.types'; jest.mock('../../../esl-utils/dom/ready', () => ({ onDocumentReady: (cb: any) => cb() @@ -26,13 +27,13 @@ describe('ESLCarouselRenderer: Slide change events created correctly', () => { }); test('ESLCarouselSlideEvent: Initial slide triggered correct events', async () => { - const request = $carousel.renderer.navigate({index: 0, direction: 'next'}, {activator: 'user', stepDuration: 0}); + const request = $carousel.renderer.navigate({index: 0, direction: ESLCarouselDirection.NEXT}, {activator: 'user', stepDuration: 0}); expect(beforeEventTrap).toHaveBeenCalledTimes(1); expect(afterEventTrap).toHaveBeenCalledTimes(0); expect(beforeEventTrap).toHaveBeenLastCalledWith(expect.objectContaining({ type: ESLCarouselSlideEvent.BEFORE, indexesAfter: [0, 1, 2], - direction: 'next', + direction: ESLCarouselDirection.NEXT, activator: 'user' })); @@ -41,20 +42,20 @@ describe('ESLCarouselRenderer: Slide change events created correctly', () => { expect(afterEventTrap).toHaveBeenLastCalledWith(expect.objectContaining({ type: ESLCarouselSlideEvent.AFTER, indexesAfter: [0, 1, 2], - direction: 'next', + direction: ESLCarouselDirection.NEXT, activator: 'user' })); }); test('ESLCarouselSlideEvent: correct events triggered in the middle state', async () => { - const request = $carousel.renderer.navigate({index: 1, direction: 'next'}, {activator: 'user', stepDuration: 0}); + const request = $carousel.renderer.navigate({index: 1, direction: ESLCarouselDirection.NEXT}, {activator: 'user', stepDuration: 0}); expect(beforeEventTrap).toHaveBeenCalledTimes(1); expect(afterEventTrap).toHaveBeenCalledTimes(0); expect(beforeEventTrap).toHaveBeenLastCalledWith(expect.objectContaining({ type: ESLCarouselSlideEvent.BEFORE, indexesBefore: [0, 1, 2], indexesAfter: [1, 2, 3], - direction: 'next', + direction: ESLCarouselDirection.NEXT, activator: 'user' })); @@ -64,20 +65,20 @@ describe('ESLCarouselRenderer: Slide change events created correctly', () => { type: ESLCarouselSlideEvent.AFTER, indexesBefore: [0, 1, 2], indexesAfter: [1, 2, 3], - direction: 'next', + direction: ESLCarouselDirection.NEXT, activator: 'user' })); }); test('ESLCarouselSlideEvent: Last slide triggered correct events', async () => { - const request = $carousel.renderer.navigate({index: 2, direction: 'next'}, {activator: 'user', stepDuration: 0}); + const request = $carousel.renderer.navigate({index: 2, direction: ESLCarouselDirection.NEXT}, {activator: 'user', stepDuration: 0}); expect(beforeEventTrap).toHaveBeenCalledTimes(1); expect(afterEventTrap).toHaveBeenCalledTimes(0); expect(beforeEventTrap).toHaveBeenLastCalledWith(expect.objectContaining({ type: ESLCarouselSlideEvent.BEFORE, indexesBefore: [1, 2, 3], indexesAfter: [2, 3, 4], - direction: 'next', + direction: ESLCarouselDirection.NEXT, activator: 'user' })); @@ -87,7 +88,7 @@ describe('ESLCarouselRenderer: Slide change events created correctly', () => { type: ESLCarouselSlideEvent.AFTER, indexesBefore: [1, 2, 3], indexesAfter: [2, 3, 4], - direction: 'next', + direction: ESLCarouselDirection.NEXT, activator: 'user' })); }); From 5285b1e8d47719806625363813eb3e8965603654 Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Fri, 16 Aug 2024 03:00:11 +0200 Subject: [PATCH 05/12] refactor(esl-carousel): move `esl-carousel.utils.ts` --- src/modules/esl-carousel/core/esl-carousel.renderer.ts | 2 +- src/modules/esl-carousel/core/esl-carousel.ts | 2 +- .../{nav/esl-carousel.nav.utils.ts => esl-carousel.utils.ts} | 4 ++-- src/modules/esl-carousel/plugin/dots/esl-carousel.nav.dots.ts | 2 +- .../esl-carousel/renderers/esl-carousel.centered.renderer.ts | 2 +- .../esl-carousel/renderers/esl-carousel.default.renderer.ts | 2 +- .../{esl-carousel.nav.test.ts => esl-carousel.utils.test.ts} | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) rename src/modules/esl-carousel/core/{nav/esl-carousel.nav.utils.ts => esl-carousel.utils.ts} (98%) rename src/modules/esl-carousel/test/core/{esl-carousel.nav.test.ts => esl-carousel.utils.test.ts} (99%) diff --git a/src/modules/esl-carousel/core/esl-carousel.renderer.ts b/src/modules/esl-carousel/core/esl-carousel.renderer.ts index 98a85b201..0f1be6b0e 100644 --- a/src/modules/esl-carousel/core/esl-carousel.renderer.ts +++ b/src/modules/esl-carousel/core/esl-carousel.renderer.ts @@ -3,7 +3,7 @@ import {isEqual} from '../../esl-utils/misc/object'; import {SyntheticEventTarget} from '../../esl-utils/dom'; import {ESLCarouselDirection} from './esl-carousel.types'; import {ESLCarouselSlideEvent} from './esl-carousel.events'; -import {indexToDirection, normalize, normalizeIndex, sequence} from './nav/esl-carousel.nav.utils'; +import {indexToDirection, normalize, normalizeIndex, sequence} from './esl-carousel.utils'; import type {ESLCarousel} from './esl-carousel'; import type {ESLCarouselSlideEventInit} from './esl-carousel.events'; diff --git a/src/modules/esl-carousel/core/esl-carousel.ts b/src/modules/esl-carousel/core/esl-carousel.ts index 50fcb5127..8c9af4ced 100644 --- a/src/modules/esl-carousel/core/esl-carousel.ts +++ b/src/modules/esl-carousel/core/esl-carousel.ts @@ -10,7 +10,7 @@ import {ESLTraversingQuery} from '../../esl-traversing-query/core'; import {ESLMediaRuleList} from '../../esl-media-query/core'; import {ESLResizeObserverTarget} from '../../esl-event-listener/core'; -import {normalize, toIndex, canNavigate} from './nav/esl-carousel.nav.utils'; +import {normalize, toIndex, canNavigate} from './esl-carousel.utils'; import {ESLCarouselSlide} from './esl-carousel.slide'; import {ESLCarouselRenderer} from './esl-carousel.renderer'; diff --git a/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts b/src/modules/esl-carousel/core/esl-carousel.utils.ts similarity index 98% rename from src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts rename to src/modules/esl-carousel/core/esl-carousel.utils.ts index e384a7bc6..42c05caf8 100644 --- a/src/modules/esl-carousel/core/nav/esl-carousel.nav.utils.ts +++ b/src/modules/esl-carousel/core/esl-carousel.utils.ts @@ -1,11 +1,11 @@ -import {ESLCarouselDirection} from '../esl-carousel.types'; +import {ESLCarouselDirection} from './esl-carousel.types'; import type { ESLCarouselNavIndex, ESLCarouselNavInfo, ESLCarouselSlideTarget, ESLCarouselState, ESLCarouselStaticState -} from '../esl-carousel.types'; +} from './esl-carousel.types'; /** @returns sign of the value */ export const sign = (value: number): -1 | 1 | 0 => value > 0 ? 1 : value < 0 ? -1 : 0; diff --git a/src/modules/esl-carousel/plugin/dots/esl-carousel.nav.dots.ts b/src/modules/esl-carousel/plugin/dots/esl-carousel.nav.dots.ts index 1183ff80c..bc807a97e 100644 --- a/src/modules/esl-carousel/plugin/dots/esl-carousel.nav.dots.ts +++ b/src/modules/esl-carousel/plugin/dots/esl-carousel.nav.dots.ts @@ -5,7 +5,7 @@ import {attr, listen, memoize, prop, ready} from '../../../esl-utils/decorators' import {ESLBaseElement} from '../../../esl-base-element/core'; import {ESLTraversingQuery} from '../../../esl-traversing-query/core'; -import {indexToGroup} from '../../core/nav/esl-carousel.nav.utils'; +import {indexToGroup} from '../../core/esl-carousel.utils'; import {ESLCarouselChangeEvent, ESLCarouselSlideEvent} from '../../core/esl-carousel.events'; import type {ESLCarousel} from '../../core/esl-carousel'; diff --git a/src/modules/esl-carousel/renderers/esl-carousel.centered.renderer.ts b/src/modules/esl-carousel/renderers/esl-carousel.centered.renderer.ts index 40d3a0c8c..8c7668bad 100644 --- a/src/modules/esl-carousel/renderers/esl-carousel.centered.renderer.ts +++ b/src/modules/esl-carousel/renderers/esl-carousel.centered.renderer.ts @@ -1,4 +1,4 @@ -import {normalize} from '../core/nav/esl-carousel.nav.utils'; +import {normalize} from '../core/esl-carousel.utils'; import {ESLCarouselRenderer} from '../core/esl-carousel.renderer'; import {ESLDefaultCarouselRenderer} from './esl-carousel.default.renderer'; diff --git a/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts b/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts index 4b6e056aa..97f8c875e 100644 --- a/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts +++ b/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts @@ -1,4 +1,4 @@ -import {normalize, sign} from '../core/nav/esl-carousel.nav.utils'; +import {normalize, sign} from '../core/esl-carousel.utils'; import {ESLCarouselRenderer} from '../core/esl-carousel.renderer'; import type {ESLCarouselDirection, ESLCarouselActionParams} from '../core/esl-carousel.types'; diff --git a/src/modules/esl-carousel/test/core/esl-carousel.nav.test.ts b/src/modules/esl-carousel/test/core/esl-carousel.utils.test.ts similarity index 99% rename from src/modules/esl-carousel/test/core/esl-carousel.nav.test.ts rename to src/modules/esl-carousel/test/core/esl-carousel.utils.test.ts index 0e21ed8be..807147c8b 100644 --- a/src/modules/esl-carousel/test/core/esl-carousel.nav.test.ts +++ b/src/modules/esl-carousel/test/core/esl-carousel.utils.test.ts @@ -4,7 +4,7 @@ import { indexToGroup, indexToDirection, toIndex, canNavigate -} from '../../core/nav/esl-carousel.nav.utils'; +} from '../../core/esl-carousel.utils'; import {ESLCarouselDirection} from '../../core/esl-carousel.types'; import type {ESLCarouselSlideTarget, ESLCarouselState} from '../../core/esl-carousel.types'; From 4bc8c908dc85a7bdaf520178cd713b7833ec84b1 Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Fri, 16 Aug 2024 03:23:41 +0200 Subject: [PATCH 06/12] feat(esl-carousel): add step animation duration customization --- src/modules/esl-carousel/core/esl-carousel.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/modules/esl-carousel/core/esl-carousel.ts b/src/modules/esl-carousel/core/esl-carousel.ts index 8c9af4ced..31836ad1f 100644 --- a/src/modules/esl-carousel/core/esl-carousel.ts +++ b/src/modules/esl-carousel/core/esl-carousel.ts @@ -3,7 +3,7 @@ import {ESLBaseElement} from '../../esl-base-element/core'; import {attr, boolAttr, ready, decorate, listen, memoize} from '../../esl-utils/decorators'; import {isMatches} from '../../esl-utils/dom/traversing'; import {microtask} from '../../esl-utils/async'; -import {parseBoolean, sequentialUID} from '../../esl-utils/misc'; +import {parseBoolean, parseTime, sequentialUID} from '../../esl-utils/misc'; import {CSSClassUtils} from '../../esl-utils/dom/class'; import {ESLTraversingQuery} from '../../esl-traversing-query/core'; @@ -33,7 +33,7 @@ import type { @ExportNs('Carousel') export class ESLCarousel extends ESLBaseElement { public static override is = 'esl-carousel'; - public static observedAttributes = ['media', 'type', 'loop', 'count', 'vertical', 'container']; + public static observedAttributes = ['media', 'type', 'loop', 'count', 'vertical', 'step-duration', 'container']; /** Media query pattern used for {@link ESLMediaRuleList} of `type`, `loop` and `count` (default: `all`) */ @attr({defaultValue: 'all'}) public media: string; @@ -46,6 +46,9 @@ export class ESLCarousel extends ESLBaseElement { /** Orientation of the carousel (`horizontal` by default). Supports {@link ESLMediaRuleList} syntax */ @attr({defaultValue: 'false'}) public vertical: string | boolean; + /** Duration of the single slide transition */ + @attr({defaultValue: '250'}) public stepDuration: string; + /** Container selector (supports traversing query). Carousel itself by default */ @attr({defaultValue: ''}) public container: string; /** CSS class to add on the container when carousel is empty */ @@ -85,6 +88,11 @@ export class ESLCarousel extends ESLBaseElement { public get verticalRule(): ESLMediaRuleList { return ESLMediaRuleList.parse(this.vertical as string, this.media, parseBoolean); } + /** Duration of the single slide transition {@link ESLMediaRuleList} instance */ + @memoize() + public get stepDurationRule(): ESLMediaRuleList { + return ESLMediaRuleList.parse(this.stepDuration, this.media, parseTime); + } /** Returns observed media rules */ public get observedRules(): ESLMediaRuleList[] { @@ -309,7 +317,8 @@ export class ESLCarousel extends ESLBaseElement { /** Merge request params with default params */ protected mergeParams(params: Partial): ESLCarouselActionParams { - return {stepDuration: 250, ...params}; + const stepDuration = this.stepDurationRule.value || 0; + return {stepDuration, ...params}; } /** @returns slide by index (supports not normalized indexes) */ From 22900a0114e4df732af66f5f7236fe8032474b31 Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Wed, 21 Aug 2024 03:02:20 +0200 Subject: [PATCH 07/12] refactor(esl-carousel): carousel events rework (split cancelable/not cancelable events) --- src/modules/esl-carousel/core/esl-carousel.events.ts | 8 +++++--- src/modules/esl-carousel/core/esl-carousel.renderer.ts | 2 +- src/modules/esl-carousel/core/esl-carousel.utils.ts | 5 ----- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/modules/esl-carousel/core/esl-carousel.events.ts b/src/modules/esl-carousel/core/esl-carousel.events.ts index 361e0daf4..3caaf9709 100644 --- a/src/modules/esl-carousel/core/esl-carousel.events.ts +++ b/src/modules/esl-carousel/core/esl-carousel.events.ts @@ -15,10 +15,12 @@ export interface ESLCarouselSlideEventInit { /** {@link ESLCarousel} event that represents slide change event */ export class ESLCarouselSlideEvent extends Event implements ESLCarouselSlideEventInit { - /** {@link ESLCarouselSlideEvent} event type dispatched before slide change (pre-event) */ + /** {@link ESLCarouselSlideEvent} cancelable event type dispatched before slide change (pre-event) */ public static readonly BEFORE = 'esl:before:slide-change'; + /** {@link ESLCarouselSlideEvent} event type dispatched before carousel is going to change active slide (post-event) */ + public static readonly CHANGE = 'esl:slide-change'; /** {@link ESLCarouselSlideEvent} event type dispatched after slide change (post-event) */ - public static readonly AFTER = 'esl:slide-change'; + public static readonly AFTER = 'esl:after:slide-change'; public override readonly target: ESLCarousel; public readonly indexesBefore: number[]; @@ -58,7 +60,7 @@ export class ESLCarouselSlideEvent extends Event implements ESLCarouselSlideEven return this.indexesAfter.map((index) => this.target.slideAt(index)); } - public static create(type: 'BEFORE' | 'AFTER', init: ESLCarouselSlideEventInit): ESLCarouselSlideEvent { + public static create(type: 'BEFORE' | 'CHANGE' | 'AFTER', init: ESLCarouselSlideEventInit): ESLCarouselSlideEvent { return new ESLCarouselSlideEvent(ESLCarouselSlideEvent[type], init); } } diff --git a/src/modules/esl-carousel/core/esl-carousel.renderer.ts b/src/modules/esl-carousel/core/esl-carousel.renderer.ts index 0f1be6b0e..e00450a46 100644 --- a/src/modules/esl-carousel/core/esl-carousel.renderer.ts +++ b/src/modules/esl-carousel/core/esl-carousel.renderer.ts @@ -113,8 +113,8 @@ export abstract class ESLCarouselRenderer implements ESLCarouselConfig { const details = {...params, direction, indexesBefore, indexesAfter}; if (!this.$carousel.dispatchEvent(ESLCarouselSlideEvent.create('BEFORE', details))) return; - // this.$carousel.dispatchEvent(ESLCarouselSlideEvent.create('CHANGE', details)); + this.$carousel.dispatchEvent(ESLCarouselSlideEvent.create('CHANGE', details)); this.setPreActive(index); try { diff --git a/src/modules/esl-carousel/core/esl-carousel.utils.ts b/src/modules/esl-carousel/core/esl-carousel.utils.ts index 42c05caf8..5e10d3883 100644 --- a/src/modules/esl-carousel/core/esl-carousel.utils.ts +++ b/src/modules/esl-carousel/core/esl-carousel.utils.ts @@ -29,11 +29,6 @@ export function sequence(current: number, count: number, size: number): number[] return result; } -/** Gets count of slides between active and passed considering given direction. */ -export function getDistance(from: number, direction: ESLCarouselDirection, {activeIndex, size}: ESLCarouselState): number { - return normalize(direction * (from - activeIndex), size); -} - /** @returns closest direction to move from the slide `from` to slide `to` */ function calcDirection(from: number, to: number, size: number): ESLCarouselDirection { const abs = Math.abs(from - to) % size; From 9ab2b6b52a027c709f9c24c95581cdc52f7d534f Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Wed, 21 Aug 2024 03:58:43 +0200 Subject: [PATCH 08/12] fix(esl-carousel): fix esl-carousel DOM manipulation and slides livecycle --- .../esl-carousel/core/esl-carousel.slide.ts | 5 +- src/modules/esl-carousel/core/esl-carousel.ts | 5 +- .../test/core/esl-carousel.manip.test.ts | 102 ++++++++++++++++++ 3 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 src/modules/esl-carousel/test/core/esl-carousel.manip.test.ts diff --git a/src/modules/esl-carousel/core/esl-carousel.slide.ts b/src/modules/esl-carousel/core/esl-carousel.slide.ts index b72fc37f2..e59e66a2e 100644 --- a/src/modules/esl-carousel/core/esl-carousel.slide.ts +++ b/src/modules/esl-carousel/core/esl-carousel.slide.ts @@ -49,7 +49,6 @@ export class ESLCarouselSlide extends ESLMixinElement { return this.$host.closest(carouselTag) as ESLCarousel | undefined; } - @ready protected override connectedCallback(): void { if (!this.$carousel) return; this.$carousel?.addSlide && this.$carousel.addSlide(this.$host); @@ -59,7 +58,9 @@ export class ESLCarouselSlide extends ESLMixinElement { } protected override disconnectedCallback(): void { - this.$carousel?.removeSlide && this.$carousel.removeSlide(this.$host); + // A disconnected callback is not directly related to slide removal from the carousel + // e.g. carousel itself can be removed from the DOM so child slides behaves accordingly + this.$carousel?.update() && this.$carousel?.update(); memoize.clear(this, '$carousel'); super.disconnectedCallback(); } diff --git a/src/modules/esl-carousel/core/esl-carousel.ts b/src/modules/esl-carousel/core/esl-carousel.ts index 31836ad1f..88eedaeb9 100644 --- a/src/modules/esl-carousel/core/esl-carousel.ts +++ b/src/modules/esl-carousel/core/esl-carousel.ts @@ -183,17 +183,14 @@ export class ESLCarousel extends ESLBaseElement { /** Appends slide instance to the current carousel */ public addSlide(slide: HTMLElement): void { - if (!slide) return; slide.setAttribute(this.slideAttrName, ''); if (slide.parentNode === this.$slidesArea) return this.update(); console.debug('[ESL]: ESLCarousel moves slide to correct location', slide); - if (slide.parentNode) slide.remove(); - Promise.resolve().then(() => this.$slidesArea.appendChild(slide)); + this.$slidesArea.appendChild(slide); } /** Remove slide instance from the current carousel */ public removeSlide(slide: HTMLElement): void { - if (!slide) return; if (slide.parentNode === this.$slidesArea) this.$slidesArea.removeChild(slide); if (this.$slides.includes(slide)) this.update(); } diff --git a/src/modules/esl-carousel/test/core/esl-carousel.manip.test.ts b/src/modules/esl-carousel/test/core/esl-carousel.manip.test.ts new file mode 100644 index 000000000..cf2bc4f49 --- /dev/null +++ b/src/modules/esl-carousel/test/core/esl-carousel.manip.test.ts @@ -0,0 +1,102 @@ +import {ESLCarousel} from '../../core/esl-carousel'; +import {ESLCarouselSlide} from '../../core/esl-carousel.slide'; +import {ESLCarouselDummyRenderer} from '../common/esl-carousel.dummy.renderer'; + +describe('ESLCarousel: DOM manipulation', () => { + const twoTicks = () => Promise.resolve().then(() => Promise.resolve()); + + beforeAll(() => { + ESLCarousel.register(); + ESLCarouselDummyRenderer.register(); + }); + + describe('ESLCarouselSlide ensure connection to the carousel inside the slide area', () => { + const $carousel = ESLCarousel.create(); + + beforeEach(async () => { + document.body.appendChild($carousel); + await ESLCarousel.registered; + }); + afterAll(() => document.body.innerHTML = ''); + + test('DOM element marked as slide outside carousel does not move itself', async () => { + const $slide = document.createElement('div'); + $slide.setAttribute('esl-carousel-slide', ''); + document.body.appendChild($slide); + await Promise.resolve(); + expect($slide.parentElement).toBe(document.body); + }); + + test('DOM element marked as slide inside carousel moves itself to the carousel area', async () => { + jest.spyOn(console, 'debug').mockImplementationOnce(() => {}); + const $slide = document.createElement('div'); + $slide.setAttribute('esl-carousel-slide', ''); + $carousel.appendChild($slide); + await twoTicks(); + expect($slide.parentElement).toBe($carousel.$slidesArea); + }); + + test('DOM element marked as slide inside carousel area does not process twice', async () => { + const $slide = document.createElement('div'); + $slide.setAttribute('esl-carousel-slide', ''); + $carousel.$slidesArea.appendChild($slide); + const connectedCallbackSpy = jest.spyOn(ESLCarouselSlide.prototype, 'connectedCallback' as any); + await twoTicks(); + expect($carousel.$slides).toContain($slide); + expect($slide.parentElement).toBe($carousel.$slidesArea); + expect(connectedCallbackSpy).toHaveBeenCalledTimes(1); + }); + + test('Usage of the ESLCarousel.addSlide method creates proper slide', async () => { + const $slide = document.createElement('div'); + $carousel.addSlide($slide); + await twoTicks(); + expect($carousel.$slides).toContain($slide); + expect($slide.parentElement).toBe($carousel.$slidesArea); + }); + + test('Removal of the slide ensures carousel be aware of it', async () => { + const $slide = document.createElement('div'); + $carousel.addSlide($slide); + await twoTicks(); + expect($carousel.$slides).toContain($slide); + expect($slide.parentElement).toBe($carousel.$slidesArea); + $slide.remove(); + await twoTicks(); + expect($carousel.$slides).not.toContain($slide); + expect($slide.parentElement).toBe(null); + }); + }); + + describe('ESLCarousel manipulation does not affect slides', () => { + const $carousel = ESLCarousel.create(); + const $slides = Array.from({length: 2}, () => { + const $slide = document.createElement('div'); + $slide.setAttribute('esl-carousel-slide', ''); + return $slide; + }); + + beforeEach(async () => { + document.body.appendChild($carousel); + await ESLCarousel.registered; + $slides.forEach(($slide) => $carousel.addSlide($slide)); + await twoTicks(); + }); + afterAll(() => document.body.innerHTML = ''); + + test('Carousel removal does not affect slides', async () => { + $carousel.remove(); + await twoTicks(); + $slides.forEach(($slide) => expect($slide.parentElement).toBe($carousel.$slidesArea)); + }); + + test('Carousel movement does not affect slides', async () => { + const $someParent = document.createElement('div'); + document.body.appendChild($someParent); + $someParent.appendChild($carousel); + await twoTicks(); + expect($carousel.parentElement).toBe($someParent); + expect($carousel.$slides).toEqual($slides); + }); + }); +}); From 45c7560b031c1256fa113fe9f1d5477f1e6279e0 Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Wed, 21 Aug 2024 04:33:39 +0200 Subject: [PATCH 09/12] fix(esl-carousel): fix initial index normalization and long animation speed --- .../esl-carousel/renderers/esl-carousel.default.renderer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts b/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts index 97f8c875e..db1e7ab3a 100644 --- a/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts +++ b/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts @@ -42,7 +42,7 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer { * Prepare to renderer animation. */ public override onBind(): void { - this.currentIndex = this.normalizeIndex(this.$carousel.activeIndex); + this.currentIndex = this.normalizeIndex(Math.max(0, this.$carousel.activeIndex)); this.redraw(); } @@ -100,7 +100,7 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer { this.currentIndex = activeIndex; if (!$slidesArea) return; const distance = normalize((nextIndex - activeIndex) * direction, this.size); - const speed = Math.max(1, distance / this.count); + const speed = Math.min(1, this.count / distance); while (this.currentIndex !== nextIndex) { await this.onStepAnimate(direction * this.INDEX_MOVE_MULTIPLIER, params.stepDuration * speed); } From eb4b9c0b165ba87395ea793d6dbe6fd27c0979c3 Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Wed, 21 Aug 2024 04:34:24 +0200 Subject: [PATCH 10/12] refactor(esl-carousel): add slide to global (debug) ns --- src/modules/esl-carousel/core/esl-carousel.slide.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/modules/esl-carousel/core/esl-carousel.slide.ts b/src/modules/esl-carousel/core/esl-carousel.slide.ts index e59e66a2e..8193b480b 100644 --- a/src/modules/esl-carousel/core/esl-carousel.slide.ts +++ b/src/modules/esl-carousel/core/esl-carousel.slide.ts @@ -1,7 +1,8 @@ import {ESLMixinElement} from '../../esl-mixin-element/core'; import {CSSClassUtils} from '../../esl-utils/dom/class'; import {microtask} from '../../esl-utils/async/microtask'; -import {attr, decorate, memoize, ready} from '../../esl-utils/decorators'; +import {ExportNs} from '../../esl-utils/environment/export-ns'; +import {attr, decorate, memoize} from '../../esl-utils/decorators'; import type {ESLCarousel} from './esl-carousel'; @@ -11,6 +12,7 @@ import type {ESLCarousel} from './esl-carousel'; * * ESLCarouselSlide - a component that provides content for ESLCarousel {@link ESLCarousel} */ +@ExportNs('Carousel.Slide') export class ESLCarouselSlide extends ESLMixinElement { public static override is = 'esl-carousel-slide'; public static override observedAttributes = ['active']; From d5a84bf6ace398b59c278c2f3027080fba0b7ca2 Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Wed, 21 Aug 2024 04:35:08 +0200 Subject: [PATCH 11/12] fix(esl-carousel): fix navigation group indexes restriction for non-loop carousels --- .../esl-carousel/core/esl-carousel.utils.ts | 2 +- .../test/core/esl-carousel.utils.test.ts | 49 +++++++++++++------ 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/modules/esl-carousel/core/esl-carousel.utils.ts b/src/modules/esl-carousel/core/esl-carousel.utils.ts index 5e10d3883..7de915a53 100644 --- a/src/modules/esl-carousel/core/esl-carousel.utils.ts +++ b/src/modules/esl-carousel/core/esl-carousel.utils.ts @@ -105,7 +105,7 @@ function resolveGroupIndex(indexStr: string | ESLCarouselNavIndex, cfg: ESLCarou if (value === 1 && normalize(cfg.activeIndex + cfg.count, cfg.size) > cfg.size - cfg.count) { return {index: cfg.size - cfg.count, direction: direction || ESLCarouselDirection.NEXT}; } - const index = normalize(cfg.activeIndex + value * cfg.count, cfg.size); + const index = normalizeIndex(cfg.activeIndex + value * cfg.count, cfg); return {index, direction: direction || indexToDirection(index, cfg)}; } diff --git a/src/modules/esl-carousel/test/core/esl-carousel.utils.test.ts b/src/modules/esl-carousel/test/core/esl-carousel.utils.test.ts index 807147c8b..def26c9ea 100644 --- a/src/modules/esl-carousel/test/core/esl-carousel.utils.test.ts +++ b/src/modules/esl-carousel/test/core/esl-carousel.utils.test.ts @@ -251,10 +251,12 @@ describe('ESLCarousel: Nav Utils', () => { describe('group relative target', () => { test.each([ - ['group: +1', {size: 5, count: 1, activeIndex: 2}, 3], - ['group: +1', {size: 5, count: 2, activeIndex: 2}, 3], - ['group: -1', {size: 5, count: 1, activeIndex: 2}, 1], - ['group: -1', {size: 5, count: 2, activeIndex: 2}, 0] + ['group: +1', {size: 5, count: 1, activeIndex: 2, loop: true}, 3], + ['group: +1', {size: 5, count: 2, activeIndex: 2, loop: true}, 3], + ['group: -1', {size: 5, count: 1, activeIndex: 2, loop: true}, 1], + ['group: -1', {size: 5, count: 2, activeIndex: 2, loop: true}, 0], + ['group: +1', {size: 5, count: 2, activeIndex: 4, loop: false}, 3], + ['group: -1', {size: 5, count: 1, activeIndex: 0, loop: false}, 0], ])( '(target = %s, cfg = %p) = %d', (target: ESLCarouselSlideTarget, cfg: ESLCarouselState, result: number) => expect(toIndex(target, cfg).index).toBe(result) @@ -263,17 +265,20 @@ describe('ESLCarousel: Nav Utils', () => { describe('group relative short target', () => { test.each([ - ['group: next', {size: 5, count: 1, activeIndex: 2}, 3], - ['group: next', {size: 5, count: 2, activeIndex: 2}, 3], - ['group: next', {size: 5, count: 1, activeIndex: 4}, 0], - ['group: next', {size: 5, count: 2, activeIndex: 4}, 1], - ['group: next', {size: 5, count: 3, activeIndex: 0}, 2], + ['group: next', {size: 5, count: 1, activeIndex: 2, loop: true}, 3], + ['group: next', {size: 5, count: 2, activeIndex: 2, loop: true}, 3], + ['group: next', {size: 5, count: 1, activeIndex: 4, loop: false}, 4], + ['group: next', {size: 5, count: 1, activeIndex: 4, loop: true}, 0], + ['group: next', {size: 5, count: 2, activeIndex: 4, loop: true}, 1], + ['group: next', {size: 5, count: 3, activeIndex: 0, loop: true}, 2], - ['group: prev', {size: 5, count: 1, activeIndex: 2}, 1], - ['group: prev', {size: 5, count: 2, activeIndex: 2}, 0], - ['group: prev', {size: 5, count: 1, activeIndex: 0}, 4], - ['group: prev', {size: 5, count: 3, activeIndex: 1}, 0], - ['group: prev', {size: 5, count: 3, activeIndex: 4}, 1] + ['group: prev', {size: 5, count: 1, activeIndex: 2, loop: true}, 1], + ['group: prev', {size: 5, count: 2, activeIndex: 2, loop: true}, 0], + ['group: prev', {size: 5, count: 1, activeIndex: 0, loop: false}, 0], + ['group: prev', {size: 5, count: 1, activeIndex: 0, loop: true}, 4], + ['group: prev', {size: 5, count: 3, activeIndex: 1, loop: false}, 0], + ['group: prev', {size: 5, count: 3, activeIndex: 1, loop: true}, 0], + ['group: prev', {size: 5, count: 3, activeIndex: 4, loop: true}, 1] ])( '(target = %s, cfg = %p) = %d', (target: ESLCarouselSlideTarget, cfg: ESLCarouselState, result: number) => expect(toIndex(target, cfg).index).toBe(result) @@ -291,7 +296,16 @@ describe('ESLCarousel: Nav Utils', () => { ['next', {activeIndex: 0, size: 5, count: 1, loop: false}], ['prev', {activeIndex: 4, size: 5, count: 1, loop: false}], ['next', {activeIndex: 2, size: 5, count: 1, loop: false}], - ['prev', {activeIndex: 2, size: 5, count: 1, loop: false}] + ['prev', {activeIndex: 2, size: 5, count: 1, loop: false}], + // Group cases + ['group: next', {activeIndex: 0, size: 5, count: 2, loop: true}], + ['group: prev', {activeIndex: 0, size: 5, count: 2, loop: true}], + ['group: next', {activeIndex: 4, size: 5, count: 2, loop: true}], + ['group: prev', {activeIndex: 4, size: 5, count: 2, loop: true}], + // Non loop case with free space + ['group: next', {activeIndex: 0, size: 5, count: 2, loop: false}], + ['group: prev', {activeIndex: 2, size: 5, count: 2, loop: false}], + ['group: prev', {activeIndex: 4, size: 5, count: 2, loop: false}] ])( 'Target %s is allowed for %p configuration', (target: ESLCarouselSlideTarget, cfg: ESLCarouselState) => expect(canNavigate(target, cfg)).toBe(true) @@ -314,7 +328,10 @@ describe('ESLCarousel: Nav Utils', () => { ['0', {activeIndex: 0, size: 5, count: 1, loop: false}], ['0', {activeIndex: 0, size: 5, count: 1, loop: true}], ['1', {activeIndex: 1, size: 5, count: 2, loop: false}], - ['1', {activeIndex: 1, size: 5, count: 2, loop: true}] + ['1', {activeIndex: 1, size: 5, count: 2, loop: true}], + // Group cases with no loop + ['group: next', {activeIndex: 4, size: 5, count: 2, loop: false}], + ['group: prev', {activeIndex: 0, size: 5, count: 2, loop: false}] ])( 'Target %s is not allowed for %p configuration', (target: ESLCarouselSlideTarget, cfg: ESLCarouselState) => expect(canNavigate(target, cfg)).toBe(false) From a3f3344041d0b52988eaa86449c7be9f3243e78b Mon Sep 17 00:00:00 2001 From: "ala'n (Alexey Stsefanovich)" Date: Wed, 21 Aug 2024 23:39:12 +0200 Subject: [PATCH 12/12] docs(esl-carousel): fix typos discovered during code review Co-authored-by: Dmytro Shovchko --- src/modules/esl-carousel/core/esl-carousel.slide.ts | 2 +- src/modules/esl-carousel/core/esl-carousel.ts | 6 +++--- src/modules/esl-carousel/core/esl-carousel.types.ts | 4 ++-- .../esl-carousel/renderers/esl-carousel.default.renderer.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/modules/esl-carousel/core/esl-carousel.slide.ts b/src/modules/esl-carousel/core/esl-carousel.slide.ts index 8193b480b..3d980ec5a 100644 --- a/src/modules/esl-carousel/core/esl-carousel.slide.ts +++ b/src/modules/esl-carousel/core/esl-carousel.slide.ts @@ -61,7 +61,7 @@ export class ESLCarouselSlide extends ESLMixinElement { protected override disconnectedCallback(): void { // A disconnected callback is not directly related to slide removal from the carousel - // e.g. carousel itself can be removed from the DOM so child slides behaves accordingly + // e.g. carousel itself can be removed from the DOM so child slides behave accordingly this.$carousel?.update() && this.$carousel?.update(); memoize.clear(this, '$carousel'); super.disconnectedCallback(); diff --git a/src/modules/esl-carousel/core/esl-carousel.ts b/src/modules/esl-carousel/core/esl-carousel.ts index 88eedaeb9..a646c77db 100644 --- a/src/modules/esl-carousel/core/esl-carousel.ts +++ b/src/modules/esl-carousel/core/esl-carousel.ts @@ -300,19 +300,19 @@ export class ESLCarousel extends ESLBaseElement { return this.renderer.navigate(toIndex(target, this.state), this.mergeParams(params)); } - /** Move slides by the passed offset */ + /** Moves slides by the passed offset */ public move(offset: number, from: number = this.activeIndex, params: Partial = {}): void { if (!this.renderer) return; this.renderer.move(offset, from, this.mergeParams(params)); } - /** Commit slides to the nearest stable position */ + /** Commits slides to the nearest stable position */ public commit(offset: number, from: number = this.activeIndex, params: Partial = {}): Promise { if (!this.renderer) return Promise.reject(); return this.renderer.commit(offset, from, this.mergeParams(params)); } - /** Merge request params with default params */ + /** Merges request params with default params */ protected mergeParams(params: Partial): ESLCarouselActionParams { const stepDuration = this.stepDurationRule.value || 0; return {stepDuration, ...params}; diff --git a/src/modules/esl-carousel/core/esl-carousel.types.ts b/src/modules/esl-carousel/core/esl-carousel.types.ts index 95775dfce..6234078ce 100644 --- a/src/modules/esl-carousel/core/esl-carousel.types.ts +++ b/src/modules/esl-carousel/core/esl-carousel.types.ts @@ -57,9 +57,9 @@ export type ESLCarouselNavInfo = { /** {@link ESLCarousel} action params interface */ export interface ESLCarouselActionParams { - /** Element requester of the change */ + /** Element that requests changes */ activator?: any; - /** Direction to move to. */ + /** Direction to move to */ direction?: ESLCarouselDirection; /** Duration of a single slide transition in milliseconds. (Set to 0 to disable animation) */ stepDuration: number; diff --git a/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts b/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts index db1e7ab3a..c695665d2 100644 --- a/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts +++ b/src/modules/esl-carousel/renderers/esl-carousel.default.renderer.ts @@ -77,7 +77,7 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer { this.$area.style.transform = `translate3d(${this.vertical ? `0px, ${offset}px` : `${offset}px, 0px`}, 0px)`; } - /** Animate scene offset to index */ + /** Animates scene offset to index */ protected async animateTo(index: number, duration = 250): Promise { this.currentIndex = this.normalizeIndex(index); const offset = -this.getOffset(this.currentIndex);