diff --git a/src/modules/esl-carousel/core/esl-carousel.events.ts b/src/modules/esl-carousel/core/esl-carousel.events.ts index bd8bfba1d..3caaf9709 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,17 +8,19 @@ 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; } /** {@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 4557f0668..e00450a46 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} from './nav/esl-carousel.nav.utils'; +import {indexToDirection, normalize, normalizeIndex, sequence} from './esl-carousel.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 {ESLCarouselSlideEventInit} from './esl-carousel.events'; +import type {ESLCarouselActionParams, ESLCarouselConfig, ESLCarouselNavInfo} from './esl-carousel.types'; export abstract class ESLCarouselRenderer implements ESLCarouselConfig { public static is: string; @@ -91,50 +92,53 @@ 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 || ESLCarouselDirection.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); - if (activeIndex === index && activeIndexes.length === this.count) return; - if (!this.$carousel.dispatchEvent(ESLCarouselSlideEvent.create('BEFORE', { - direction, - activator, - indexesBefore: activeIndexes, - indexesAfter: sequence(index, this.count, this.size) - }))) return; + const indexesAfter = sequence(index, this.count, this.size); + const indexesBefore = this.$carousel.activeIndexes; + if (indexesBefore.toString() === indexesAfter.toString()) 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); } - this.setActive(index, {direction, activator}); + this.setActive(index, {direction, ...params}); } /** 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.slide.ts b/src/modules/esl-carousel/core/esl-carousel.slide.ts index b72fc37f2..3d980ec5a 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']; @@ -49,7 +51,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 +60,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 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 cc14a338f..a646c77db 100644 --- a/src/modules/esl-carousel/core/esl-carousel.ts +++ b/src/modules/esl-carousel/core/esl-carousel.ts @@ -3,14 +3,14 @@ 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'; 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'; @@ -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 @@ -43,7 +33,7 @@ export interface ESLCarouselActionParams { @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; @@ -56,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 */ @@ -95,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[] { @@ -169,6 +167,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})); } @@ -184,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(); } @@ -298,12 +294,28 @@ 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(); - 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), this.mergeParams(params)); + } + + /** 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)); + } + + /** 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)); + } + + /** Merges request params with default params */ + protected mergeParams(params: Partial): ESLCarouselActionParams { + const stepDuration = this.stepDurationRule.value || 0; + return {stepDuration, ...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 76% 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..6234078ce 100644 --- a/src/modules/esl-carousel/core/nav/esl-carousel.nav.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; @@ -47,5 +52,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 that requests changes */ + activator?: any; + /** 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/core/nav/esl-carousel.nav.utils.ts b/src/modules/esl-carousel/core/esl-carousel.utils.ts similarity index 79% 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 6ee3b291f..7de915a53 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,14 @@ +import {ESLCarouselDirection} from './esl-carousel.types'; import type { - ESLCarouselDirection, ESLCarouselNavIndex, ESLCarouselNavInfo, ESLCarouselSlideTarget, ESLCarouselState, ESLCarouselStaticState -} from './esl-carousel.nav.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; /** @returns normalized slide index in bounds of [0, count] range */ export function normalize(index: number, size: number): number { @@ -14,7 +17,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 */ @@ -26,21 +29,10 @@ 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 { - if (direction === 'prev') return normalize(activeIndex - from, size); - if (direction === 'next') return normalize(from - activeIndex, size); - return 0; -} - -/** @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 */ @@ -66,11 +58,8 @@ 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 { - if (loop) return calcDirection(activeIndex, index, size); - if (activeIndex < index) return 'next'; - if (activeIndex > index) return 'prev'; - return null; +export function indexToDirection(index: number, {activeIndex, size, loop}: ESLCarouselState): ESLCarouselDirection | undefined { + return loop ? calcDirection(activeIndex, index, size) : sign(index - activeIndex); } /** Splits target string into type and index parts */ @@ -87,10 +76,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}; } @@ -111,12 +100,12 @@ 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); + const index = normalizeIndex(cfg.activeIndex + value * cfg.count, cfg); return {index, direction: direction || indexToDirection(index, cfg)}; } @@ -129,7 +118,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}; } /** @@ -139,7 +128,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/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/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/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= 0 ? this.$carousel.activeIndex : 0; + this.currentIndex = this.normalizeIndex(Math.max(0, this.$carousel.activeIndex)); this.redraw(); } @@ -77,31 +77,37 @@ export class ESLDefaultCarouselRenderer extends ESLCarouselRenderer { this.$area.style.transform = `translate3d(${this.vertical ? `0px, ${offset}px` : `${offset}px, 0px`}, 0px)`; } - /** Animate scene offset */ - protected async animateTransformOffset(offset: number = -this.getOffset(this.currentIndex)): Promise { + /** Animates 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 distance = normalize((nextIndex - activeIndex) * direction, this.size); + const speed = Math.min(1, this.count / distance); + while (this.currentIndex !== nextIndex) { + await this.onStepAnimate(direction * 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 +115,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; @@ -142,13 +146,13 @@ 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)}); } } /** 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,15 +160,14 @@ 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)); 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/renderers/esl-carousel.grid.renderer.ts b/src/modules/esl-carousel/renderers/esl-carousel.grid.renderer.ts index 55c6cdd30..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,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} from '../core/esl-carousel.types'; /** * {@link ESLDefaultCarouselRenderer} extension to render slides as a multi-row grid. @@ -79,20 +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}); - } - - /** 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); + protected override normalizeIndex(index: number, params?: ESLCarouselActionParams): number { + return super.normalizeIndex(index - (index % this.ROWS), params); } /** 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..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/nav/esl-carousel.nav.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.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); + }); + }); +}); 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..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(0, 'next', {activator: 'user'}); + 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(1, 'next', {activator: 'user'}); + 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(2, 'next', {activator: 'user'}); + 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' })); }); 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 64% 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 2d73f8656..def26c9ea 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 @@ -3,14 +3,11 @@ import { groupToIndex, indexToGroup, indexToDirection, - toIndex -} from '../../core/nav/esl-carousel.nav.utils'; + toIndex, canNavigate +} from '../../core/esl-carousel.utils'; +import {ESLCarouselDirection} from '../../core/esl-carousel.types'; -import type { - ESLCarouselDirection, - ESLCarouselSlideTarget, - ESLCarouselState -} from '../../core/nav/esl-carousel.nav.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) @@ -254,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) @@ -266,21 +265,76 @@ 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) ); }); }); + + 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}], + // 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) + ); + + 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}], + // 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) + ); + }); });