From c02406ba13393e931e5f60361fc657b95883591d Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Fri, 20 Jun 2025 13:54:17 +0200 Subject: [PATCH 01/16] Add a Carousel component --- package-lock.json | 7 + packages/ui/Carousel/AbstractCarouselChild.ts | 72 ++++++++ packages/ui/Carousel/Carousel.ts | 159 ++++++++++++++++++ packages/ui/Carousel/CarouselBtn.ts | 59 +++++++ packages/ui/Carousel/CarouselDrag.ts | 93 ++++++++++ packages/ui/Carousel/CarouselItem.ts | 58 +++++++ packages/ui/Carousel/CarouselProgress.ts | 28 +++ packages/ui/Carousel/CarouselWrapper.ts | 62 +++++++ packages/ui/Carousel/index.ts | 7 + packages/ui/Carousel/utils.ts | 21 +++ packages/ui/index.ts | 1 + packages/ui/package.json | 1 + 12 files changed, 568 insertions(+) create mode 100644 packages/ui/Carousel/AbstractCarouselChild.ts create mode 100644 packages/ui/Carousel/Carousel.ts create mode 100644 packages/ui/Carousel/CarouselBtn.ts create mode 100644 packages/ui/Carousel/CarouselDrag.ts create mode 100644 packages/ui/Carousel/CarouselItem.ts create mode 100644 packages/ui/Carousel/CarouselProgress.ts create mode 100644 packages/ui/Carousel/CarouselWrapper.ts create mode 100644 packages/ui/Carousel/index.ts create mode 100644 packages/ui/Carousel/utils.ts diff --git a/package-lock.json b/package-lock.json index e3fd47c8..8ad0bd2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5357,6 +5357,12 @@ "node": ">= 10" } }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -17082,6 +17088,7 @@ "version": "1.0.1", "license": "MIT", "dependencies": { + "compute-scroll-into-view": "^3.1.0", "deepmerge": "^4.3.1", "morphdom": "^2.7.5" }, diff --git a/packages/ui/Carousel/AbstractCarouselChild.ts b/packages/ui/Carousel/AbstractCarouselChild.ts new file mode 100644 index 00000000..a1e7281d --- /dev/null +++ b/packages/ui/Carousel/AbstractCarouselChild.ts @@ -0,0 +1,72 @@ +import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import { Base, getClosestParent } from '@studiometa/js-toolkit'; +import { Carousel } from './Carousel.js'; + +/** + * AbstractCarouselChild class. + */ +export class AbstractCarouselChild extends Base { + /** + * Config. + */ + static config: BaseConfig = { + name: 'AbstractCarouselChild', + emits: ['parent-carousel-go-to', 'parent-carousel-progress'], + }; + + /** + * Get the parent carousel instance. + * @todo data-option-carousel for better grouping? + */ + get carousel() { + return getClosestParent(this, Carousel); + } + + /** + * Is the carousel horizontal? + */ + get isHorizontal() { + return this.carousel.isHorizontal; + } + + /** + * Is the carousel vertical? + */ + get isVertical() { + return this.carousel.isVertical; + } + + /** + * Disptach events from the parent carousel on the child components. + */ + handleEvent(event: CustomEvent) { + switch (event.type) { + case 'go-to': + case 'progress': + this.$emit(`parent-carousel-${event.type}`, ...event.detail); + break; + } + } + + /** + * Mounted hook. + */ + mounted() { + if (!this.carousel) { + this.$warn('Could not find a parent slider, not mounting.'); + this.$destroy(); + return; + } + + this.carousel.$on('go-to', this); + this.carousel.$on('progress', this); + } + + /** + * Destroyed hook. + */ + destroyed() { + this.carousel?.$off('go-to', this); + this.carousel?.$off('progress', this); + } +} diff --git a/packages/ui/Carousel/Carousel.ts b/packages/ui/Carousel/Carousel.ts new file mode 100644 index 00000000..5ca17a39 --- /dev/null +++ b/packages/ui/Carousel/Carousel.ts @@ -0,0 +1,159 @@ +import { Base } from '@studiometa/js-toolkit'; +import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import { CarouselBtn } from './CarouselBtn.js'; +import { CarouselDrag } from './CarouselDrag.js'; +import { CarouselItem } from './CarouselItem.js'; +import { CarouselProgress } from './CarouselProgress.js'; +import { CarouselWrapper } from './CarouselWrapper.js'; + +/** + * Props for the Carousel class. + */ +export interface CarouselProps { + $children: { + CarouselBtn: CarouselBtn[]; + CarouselDrag: CarouselDrag[]; + CarouselItem: CarouselItem[]; + CarouselProgress: CarouselProgress[]; + CarouselWrapper: CarouselWrapper[]; + }; + $options: { + axis: 'x' | 'y'; + }; +} + +/** + * Carousel class. + */ +export class Carousel extends Base { + /** + * Config. + */ + static config: BaseConfig = { + name: 'Slider', + components: { + CarouselBtn, + CarouselDrag, + CarouselItem, + CarouselProgress, + CarouselWrapper, + }, + options: { + axis: { type: String, default: 'x' }, + }, + emits: ['go-to', 'progress'], + }; + + /** + * Carousel index. + */ + __index = 0; + + /** + * Get current index. + */ + get index() { + return this.__index; + } + + /** + * Set current index. + */ + set index(value) { + this.__index = value; + } + + /** + * Previous index. + */ + get prevIndex() { + return Math.max(this.index - 1, 0); + } + + /** + * Next index. + */ + get nextIndex() { + return Math.min(this.index + 1, this.lastIndex); + } + + /** + * Last index. + */ + get lastIndex() { + return this.items.length - 1; + } + + /** + * Is the carousel horizontal? + */ + get isHorizontal() { + return !this.isVertical; + } + + /** + * Is the carousel vertical? + */ + get isVertical() { + return this.$options.axis === 'y'; + } + + /** + * Get the carousel's items. + */ + get items() { + return this.$children.CarouselItem; + } + + /** + * Get the carousel's wrapper. + */ + get wrapper() { + return this.$children.CarouselWrapper?.[0]; + } + + /** + * Progress from 0 to 1. + */ + get progress() { + return this.wrapper?.progress ?? 0; + } + + /** + * Mounted hook. + */ + mounted() { + this.goTo(this.index); + } + + /** + * Resized hook. + */ + resized() { + this.goTo(this.index); + } + + /** + * Go to the previous item. + */ + goPrev() { + this.goTo(this.prevIndex); + } + + /** + * Go to the next item. + */ + goNext() { + this.goTo(this.nextIndex); + } + + /** + * Go to the given item. + */ + goTo(index: number) { + this.$log('goTo', index); + this.index = index; + this.$emit('go-to', index); + this.$emit('progress', this.progress); + } +} diff --git a/packages/ui/Carousel/CarouselBtn.ts b/packages/ui/Carousel/CarouselBtn.ts new file mode 100644 index 00000000..e424896e --- /dev/null +++ b/packages/ui/Carousel/CarouselBtn.ts @@ -0,0 +1,59 @@ +import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import { AbstractCarouselChild } from './AbstractCarouselChild.js'; + +/** + * Props for the CarouselBtn class. + */ +export interface CarouselBtnProps extends BaseProps { + $el: HTMLButtonElement; + $options: { + action: 'next' | 'prev' | string; + }; +} + +/** + * CarouselBtn class. + */ +export class CarouselBtn extends AbstractCarouselChild< + T & CarouselBtnProps +> { + /** + * Config. + */ + static config: BaseConfig = { + name: 'CarouselBtn', + options: { action: String }, + }; + + /** + * Go to the next or previous item on click. + */ + onClick() { + const { action } = this.$options; + switch (action) { + case 'next': + this.carousel.goNext(); + break; + case 'prev': + this.carousel.goPrev(); + break; + default: + this.carousel.goTo(Number(action)); + break; + } + } + + /** + * Update button state on parent carousel progress. + */ + onParentCarouselProgress() { + const { action } = this.$options; + const { index, lastIndex } = this.carousel; + const shouldDisable = + (action === 'next' && index === lastIndex) || + (action === 'prev' && index === 0) || + Number(action) === index; + + this.$el.disabled = shouldDisable; + } +} diff --git a/packages/ui/Carousel/CarouselDrag.ts b/packages/ui/Carousel/CarouselDrag.ts new file mode 100644 index 00000000..8b3c7946 --- /dev/null +++ b/packages/ui/Carousel/CarouselDrag.ts @@ -0,0 +1,93 @@ +import type { BaseConfig, BaseProps, DragServiceProps } from '@studiometa/js-toolkit'; +import { withDrag, withMountOnMediaQuery } from '@studiometa/js-toolkit'; +import { inertiaFinalValue } from '@studiometa/js-toolkit/utils'; +import { AbstractCarouselChild } from './AbstractCarouselChild.js'; +import { getClosestIndex } from './utils.js'; + +/** + * Props for the CarouselDrag class. + */ +export interface CarouselDragProps extends BaseProps {} + +/** + * CarouselDrag class. + */ +export class CarouselDrag< + T extends BaseProps = BaseProps, +> extends withMountOnMediaQuery( + withDrag(AbstractCarouselChild), + '(pointer: fine)', +) { + /** + * Config. + */ + static config: BaseConfig = { + name: 'CarouselDrag', + }; + + /** + * Dragged hook. + */ + dragged(props: DragServiceProps) { + if (!this.$isMounted) return; + + // do noting on inertia and stop + if (props.mode === 'inertia' || props.mode === 'stop') { + return; + } + + // do nothin while the distance is 0 + if ( + (this.isHorizontal && props.distance.x === 0) || + (this.isVertical && props.distance.y === 0) + ) { + return; + } + + const wrapper = this.$el; + + // @todo wait for the props.delta values to be fixed + // @see https://github.com/studiometa/js-toolkit/pull/533 + if (props.mode === 'drag') { + const left = wrapper.scrollLeft - props.delta.x; + const top = wrapper.scrollTop - props.delta.y; + // We must disable the scroll-snap otherwise we + // cannot programmatically scroll to a position + // that is not a snap-point. This might be easily + // fixed by not using scroll-snap at all. + wrapper.style.scrollSnapType = 'none'; + wrapper.scrollTo({ left, top, behavior: 'instant' }); + return; + } + + // @todo implement inertia with the raf service for a smoother transition than the native smooth scroll + if (props.mode === 'drop') { + const options: ScrollToOptions = { behavior: 'smooth' }; + + if (this.isHorizontal) { + const finalValue = inertiaFinalValue(wrapper.scrollLeft, props.delta.x * -2.5); + const index = getClosestIndex( + this.carousel.items.map((item) => item.state.left), + finalValue, + ); + options.left = this.carousel.items[index].state.left; + } else if (this.isVertical) { + const finalValue = inertiaFinalValue(wrapper.scrollTop, props.delta.y * -2.5); + const index = getClosestIndex( + this.carousel.items.map((item) => item.state.top), + finalValue, + ); + options.top = this.carousel.items[index].state.top; + } + + wrapper.addEventListener( + 'scrollend', + () => { + wrapper.style.scrollSnapType = ''; + }, + { once: true }, + ); + wrapper.scrollTo(options); + } + } +} diff --git a/packages/ui/Carousel/CarouselItem.ts b/packages/ui/Carousel/CarouselItem.ts new file mode 100644 index 00000000..89038f65 --- /dev/null +++ b/packages/ui/Carousel/CarouselItem.ts @@ -0,0 +1,58 @@ +import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import type { ScrollAction } from 'compute-scroll-into-view'; +import { compute } from 'compute-scroll-into-view'; +import { AbstractCarouselChild } from './AbstractCarouselChild.js'; + +/** + * Props for the CarouselItem class. + */ +export interface CarouselItemProps extends BaseProps {} + +/** + * CarouselItem class. + */ +export class CarouselItem extends AbstractCarouselChild< + T & CarouselItemProps +> { + /** + * Config. + */ + static config: BaseConfig = { + name: 'CarouselItem', + }; + + /** + * The item's index in the carousel. + */ + get index() { + return this.carousel.$children.CarouselItem.indexOf(this); + } + + /** + * The item's active state descriptor. + */ + get state(): ScrollAction { + const [state] = compute(this.$el, { + block: 'center', + inline: 'center', + boundary: this.carousel.wrapper.$el, + }); + return state; + } + + timer: number; + + /** + * Update the item's state on parent carousel progress. + * @todo a11y + */ + onParentCarouselProgress() { + window.clearTimeout(this.timer); + this.timer = window.setTimeout(() => { + this.$el.style.setProperty( + '--carousel-item-active', + String(Number(this.index === this.carousel.index)), + ); + }, 16); + } +} diff --git a/packages/ui/Carousel/CarouselProgress.ts b/packages/ui/Carousel/CarouselProgress.ts new file mode 100644 index 00000000..18484d45 --- /dev/null +++ b/packages/ui/Carousel/CarouselProgress.ts @@ -0,0 +1,28 @@ +import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import { AbstractCarouselChild } from './AbstractCarouselChild.js'; + +/** + * Props for the CarouselProgress class. + */ +export interface CarouselProgressProps extends BaseProps {} + +/** + * CarouselProgress class. + */ +export class CarouselProgress extends AbstractCarouselChild< + T & CarouselProgressProps +> { + /** + * Config. + */ + static config: BaseConfig = { + name: 'CarouselProgress', + }; + + /** + * Update track style on parent carousel progress. + */ + onParentCarouselProgress() { + this.$el.style.setProperty('--carousel-progress', String(this.carousel.progress)); + } +} diff --git a/packages/ui/Carousel/CarouselWrapper.ts b/packages/ui/Carousel/CarouselWrapper.ts new file mode 100644 index 00000000..53a2d9fa --- /dev/null +++ b/packages/ui/Carousel/CarouselWrapper.ts @@ -0,0 +1,62 @@ +import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import { AbstractCarouselChild } from './AbstractCarouselChild.js'; +import { getClosestIndex } from './utils.js'; + +/** + * Props for the CarouselWrapper class. + */ +export interface CarouselWrapperProps extends BaseProps {} + +/** + * CarouselWrapper class. + */ +export class CarouselWrapper extends AbstractCarouselChild< + T & CarouselWrapperProps +> { + /** + * Config. + */ + static config: BaseConfig = { + name: 'CarouselWrapper', + }; + + /** + * Current progress between 0 and 1. + */ + get progress() { + if (this.isHorizontal) { + const { scrollLeft, scrollWidth, offsetWidth } = this.$el; + return scrollWidth - offsetWidth === 0 ? 0 : scrollLeft / (scrollWidth - offsetWidth); + } else if (this.isVertical) { + const { scrollTop, scrollHeight, offsetHeight } = this.$el; + return scrollHeight - offsetHeight === 0 ? 0 : scrollTop / (scrollHeight - offsetHeight); + } + + return 0; + } + + /** + * Update index and emit progress on wrapper scroll. + */ + onScroll() { + const { isHorizontal, $el, carousel } = this; + + const minDiffIndex = getClosestIndex( + carousel.items.map((item) => (isHorizontal ? item.state.left : item.state.top)), + isHorizontal ? $el.scrollLeft : $el.scrollTop, + ); + + carousel.index = minDiffIndex; + carousel.$emit('progress', this.progress); + } + + /** + * Scroll to the new item on parent carousel go-to event. + */ + onParentCarouselGoTo() { + const { state } = this.carousel.items[this.carousel.index]; + if (state) { + this.$el.scrollTo({ left: state.left, top: state.top, behavior: 'smooth' }); + } + } +} diff --git a/packages/ui/Carousel/index.ts b/packages/ui/Carousel/index.ts new file mode 100644 index 00000000..a9a03ec8 --- /dev/null +++ b/packages/ui/Carousel/index.ts @@ -0,0 +1,7 @@ +export * from './Carousel.js'; +export * from './CarouselBtn.js'; +export * from './CarouselDrag.js'; +export * from './CarouselItem.js'; +export * from './CarouselProgress.js'; +export * from './CarouselWrapper.js'; +export * from './AbstractCarouselChild.js'; diff --git a/packages/ui/Carousel/utils.ts b/packages/ui/Carousel/utils.ts new file mode 100644 index 00000000..aa57acf8 --- /dev/null +++ b/packages/ui/Carousel/utils.ts @@ -0,0 +1,21 @@ +/** + * Get the index of the closest number to the target. + */ +export function getClosestIndex(numbers: number[], target: number): number { + let index = 0; + let min = Number.POSITIVE_INFINITY; + let closestIndex = 0; + + for (const number of numbers) { + const absoluteDiff = Math.abs(number - target); + + if (absoluteDiff < min) { + closestIndex = index; + min = absoluteDiff; + } + + index += 1; + } + + return closestIndex; +} diff --git a/packages/ui/index.ts b/packages/ui/index.ts index 48ca4f4d..14a69762 100644 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -2,6 +2,7 @@ export * from './Accordion/index.js'; export * from './Action/index.js'; export * from './AnchorNav/index.js'; export * from './AnchorScrollTo/index.js'; +export * from './Carousel/index.js'; export * from './CircularMarquee/index.js'; export * from './Cursor/index.js'; export * from './Data/index.js'; diff --git a/packages/ui/package.json b/packages/ui/package.json index 841b998e..f972785a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -29,6 +29,7 @@ }, "homepage": "https://github.com/studiometa/ui#readme", "dependencies": { + "compute-scroll-into-view": "^3.1.0", "deepmerge": "^4.3.1", "morphdom": "^2.7.5" }, From 3251909756dff0510e117e34148b0fb6720aea01 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Fri, 20 Jun 2025 14:39:55 +0200 Subject: [PATCH 02/16] Add docs --- packages/docs/components/Carousel/examples.md | 23 ++++++++ packages/docs/components/Carousel/index.md | 45 ++++++++++++++ .../Carousel/stories/horizontal/app.js | 13 ++++ .../Carousel/stories/horizontal/app.twig | 57 ++++++++++++++++++ .../Carousel/stories/vertical/app.js | 13 ++++ .../Carousel/stories/vertical/app.twig | 59 +++++++++++++++++++ packages/playground/meta.config.js | 1 + .../static/compute-scroll-into-view.js | 1 + 8 files changed, 212 insertions(+) create mode 100644 packages/docs/components/Carousel/examples.md create mode 100644 packages/docs/components/Carousel/index.md create mode 100644 packages/docs/components/Carousel/stories/horizontal/app.js create mode 100644 packages/docs/components/Carousel/stories/horizontal/app.twig create mode 100644 packages/docs/components/Carousel/stories/vertical/app.js create mode 100644 packages/docs/components/Carousel/stories/vertical/app.twig create mode 100644 packages/playground/static/compute-scroll-into-view.js diff --git a/packages/docs/components/Carousel/examples.md b/packages/docs/components/Carousel/examples.md new file mode 100644 index 00000000..3a4bf00f --- /dev/null +++ b/packages/docs/components/Carousel/examples.md @@ -0,0 +1,23 @@ +--- +title: Carousel examples +--- + +# Examples + +## Horizontal + + + +## Vertical + + diff --git a/packages/docs/components/Carousel/index.md b/packages/docs/components/Carousel/index.md new file mode 100644 index 00000000..ada7d798 --- /dev/null +++ b/packages/docs/components/Carousel/index.md @@ -0,0 +1,45 @@ +--- +badges: [JS] +--- + +# Carousel + +## Table of content + +- [Examples](./examples.md) + +## Usage + +Use the `Carousel` component to display a carousel with native scroll capabilities. + +::: code-group + +```js twoslash [app.js] +import { Base, createApp } from '@studiometa/js-toolkit'; +import { Carousel } from '@studiometa/ui'; + +class App extends Base { + static config = { + name: 'App', + components: { + Carousel, + }, + }; +} + +export default createApp(App); +``` + +```twig [carousel.twig] +
+
+ {% for item in 1..4 %} +
+ #{{ item }} +
+ {% endfor %} +
+
+``` + +::: diff --git a/packages/docs/components/Carousel/stories/horizontal/app.js b/packages/docs/components/Carousel/stories/horizontal/app.js new file mode 100644 index 00000000..380e8434 --- /dev/null +++ b/packages/docs/components/Carousel/stories/horizontal/app.js @@ -0,0 +1,13 @@ +import { Base, createApp } from '@studiometa/js-toolkit'; +import { Carousel } from '@studiometa/ui'; + +class App extends Base { + static config = { + name: 'App', + components: { + Carousel, + }, + }; +} + +createApp(App); diff --git a/packages/docs/components/Carousel/stories/horizontal/app.twig b/packages/docs/components/Carousel/stories/horizontal/app.twig new file mode 100644 index 00000000..3cbdfa84 --- /dev/null +++ b/packages/docs/components/Carousel/stories/horizontal/app.twig @@ -0,0 +1,57 @@ +{% set colors = ['red', 'green', 'blue', 'purple'] %} +{% set count = 5 %} + +
+
+ {% for i in 1..count %} + {% set color = colors[loop.index0 % (colors|length)] %} +
+ N°{{ i }} +
+ {% endfor %} +
+
+
+
+ +
diff --git a/packages/docs/components/Carousel/stories/vertical/app.js b/packages/docs/components/Carousel/stories/vertical/app.js new file mode 100644 index 00000000..380e8434 --- /dev/null +++ b/packages/docs/components/Carousel/stories/vertical/app.js @@ -0,0 +1,13 @@ +import { Base, createApp } from '@studiometa/js-toolkit'; +import { Carousel } from '@studiometa/ui'; + +class App extends Base { + static config = { + name: 'App', + components: { + Carousel, + }, + }; +} + +createApp(App); diff --git a/packages/docs/components/Carousel/stories/vertical/app.twig b/packages/docs/components/Carousel/stories/vertical/app.twig new file mode 100644 index 00000000..75fe5200 --- /dev/null +++ b/packages/docs/components/Carousel/stories/vertical/app.twig @@ -0,0 +1,59 @@ +{% set colors = ['red', 'green', 'blue', 'purple'] %} +{% set count = 5 %} + +
+
+ {% for i in 1..count %} + {% set color = colors[loop.index0 % (colors|length)] %} +
+ N°{{ i }} +
+ {% endfor %} +
+
+
+
+ +
diff --git a/packages/playground/meta.config.js b/packages/playground/meta.config.js index b063b16c..376d51b9 100644 --- a/packages/playground/meta.config.js +++ b/packages/playground/meta.config.js @@ -28,6 +28,7 @@ export default defineWebpackConfig({ '@studiometa/ui': '/-/play/static/ui/index.js', deepmerge: '/-/play/static/deepmerge.js', morphdom: '/-/play/static/morphdom.js', + 'compute-scroll-into-view': '/-/play/static/compute-scroll-into-view.js', }, defaults: { html: `{% html_element 'span' with { class: 'dark:text-white font-bold border-b-2 border-current' } %} diff --git a/packages/playground/static/compute-scroll-into-view.js b/packages/playground/static/compute-scroll-into-view.js new file mode 100644 index 00000000..a584246f --- /dev/null +++ b/packages/playground/static/compute-scroll-into-view.js @@ -0,0 +1 @@ +export * from 'compute-scroll-into-view'; From 22828d5e652a4ffa485eb80d00e25b9600374ad5 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Fri, 20 Jun 2025 15:01:45 +0200 Subject: [PATCH 03/16] Add tests --- .../Carousel/AbstractCarouselChild.spec.ts | 47 ++++++ packages/tests/Carousel/Carousel.spec.ts | 86 +++++++++++ packages/tests/Carousel/CarouselBtn.spec.ts | 42 ++++++ packages/tests/Carousel/CarouselDrag.spec.ts | 134 +++++++++++++++++ packages/tests/Carousel/CarouselItem.spec.ts | 44 ++++++ .../tests/Carousel/CarouselProgress.spec.ts | 14 ++ .../tests/Carousel/CarouselWrapper.spec.ts | 140 ++++++++++++++++++ packages/tests/Carousel/utils.spec.ts | 15 ++ packages/tests/__utils__/index.ts | 1 + packages/tests/__utils__/matchMedia.ts | 127 ++++++++++++++++ packages/tests/index.spec.ts | 7 + 11 files changed, 657 insertions(+) create mode 100644 packages/tests/Carousel/AbstractCarouselChild.spec.ts create mode 100644 packages/tests/Carousel/Carousel.spec.ts create mode 100644 packages/tests/Carousel/CarouselBtn.spec.ts create mode 100644 packages/tests/Carousel/CarouselDrag.spec.ts create mode 100644 packages/tests/Carousel/CarouselItem.spec.ts create mode 100644 packages/tests/Carousel/CarouselProgress.spec.ts create mode 100644 packages/tests/Carousel/CarouselWrapper.spec.ts create mode 100644 packages/tests/Carousel/utils.spec.ts create mode 100644 packages/tests/__utils__/matchMedia.ts diff --git a/packages/tests/Carousel/AbstractCarouselChild.spec.ts b/packages/tests/Carousel/AbstractCarouselChild.spec.ts new file mode 100644 index 00000000..17f1a321 --- /dev/null +++ b/packages/tests/Carousel/AbstractCarouselChild.spec.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi } from 'vitest'; +import { AbstractCarouselChild, Carousel } from '@studiometa/ui'; +import { h, mount, destroy } from '#test-utils'; + +describe('The AbstractCarouselChild class', () => { + it('should not mount if it can not find a parent Carousel', async () => { + const div = h('div'); + const child = new AbstractCarouselChild(div); + await mount(child); + expect(child.$isMounted).toBe(false); + }); + + it('should listen to its parent events and dispatch them', async () => { + const childElement = h('div'); + const carouselElement = h('div', [childElement]); + const carousel = new Carousel(carouselElement); + const child = new AbstractCarouselChild(childElement); + await mount(carousel, child); + expect(child.carousel).toBe(carousel); + const spy = vi.spyOn(child, '$emit'); + + for (const eventName of ['go-to', 'progress']) { + carousel.$emit(eventName, 0); + expect(spy).toHaveBeenCalledExactlyOnceWith(`parent-carousel-${eventName}`, 0); + spy.mockClear(); + } + + await destroy(child); + spy.mockClear(); + + for (const eventName of ['go-to', 'progress']) { + carousel.$emit(eventName, 0); + expect(spy).not.toHaveBeenCalled(); + spy.mockClear(); + } + }); + + it('should expose the parent carousel isHorizontal and isVertical getters', async () => { + const childElement = h('div'); + const carouselElement = h('div', [childElement]); + const carousel = new Carousel(carouselElement); + const child = new AbstractCarouselChild(childElement); + await mount(carousel, child); + expect(child.isHorizontal).toBe(carousel.isHorizontal); + expect(child.isVertical).toBe(carousel.isVertical); + }); +}); diff --git a/packages/tests/Carousel/Carousel.spec.ts b/packages/tests/Carousel/Carousel.spec.ts new file mode 100644 index 00000000..e989ad88 --- /dev/null +++ b/packages/tests/Carousel/Carousel.spec.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Carousel } from '@studiometa/ui'; +import { h, mount } from '#test-utils'; + +describe('The Carousel class', () => { + it('should have an axis option', async () => { + const div = h('div'); + const carousel = new Carousel(div); + await mount(carousel); + expect(carousel.isHorizontal).toBe(true); + expect(carousel.isVertical).toBe(false); + div.setAttribute('data-option-axis', 'y'); + expect(carousel.isHorizontal).toBe(false); + expect(carousel.isVertical).toBe(true); + }); + + it('should emit go-to and progress events', async () => { + const div = h('div'); + const carousel = new Carousel(div); + const goToFn = vi.fn(); + const progressFn = vi.fn(); + carousel.$on('go-to', goToFn); + carousel.$on('progress', progressFn); + carousel.goTo(0); + expect(goToFn).toHaveBeenCalledOnce(); + expect(goToFn.mock.lastCall[0].detail).toEqual([0]); + expect(progressFn).toHaveBeenCalledOnce(); + carousel.goTo(1); + expect(goToFn.mock.lastCall[0].detail).toEqual([1]); + }); + + it('should implement an indexable API', async () => { + const items = [ + h('div', { dataComponent: 'CarouselItem' }), + h('div', { dataComponent: 'CarouselItem' }), + h('div', { dataComponent: 'CarouselItem' }), + h('div', { dataComponent: 'CarouselItem' }), + ]; + const wrapper = h('div', { dataComponent: 'CarouselWrapper' }, items); + const div = h('div', [wrapper]); + const carousel = new Carousel(div); + await mount(carousel); + + expect(carousel.index).toBe(0); + expect(carousel.prevIndex).toBe(0); + expect(carousel.nextIndex).toBe(1); + expect(carousel.lastIndex).toBe(3); + + carousel.goTo(1); + + expect(carousel.index).toBe(1); + expect(carousel.prevIndex).toBe(0); + expect(carousel.nextIndex).toBe(2); + expect(carousel.lastIndex).toBe(3); + + carousel.goNext(); + + expect(carousel.index).toBe(2); + expect(carousel.prevIndex).toBe(1); + expect(carousel.nextIndex).toBe(3); + expect(carousel.lastIndex).toBe(3); + + carousel.goPrev(); + + expect(carousel.index).toBe(1); + expect(carousel.prevIndex).toBe(0); + expect(carousel.nextIndex).toBe(2); + expect(carousel.lastIndex).toBe(3); + }); + + it('should go to the current index on mount', async () => { + const div = h('div'); + const carousel = new Carousel(div); + const spy = vi.spyOn(carousel, 'goTo'); + await mount(carousel); + expect(spy).toHaveBeenCalledExactlyOnceWith(carousel.index); + }); + + it('should go to the current index on resize', async () => { + const div = h('div'); + const carousel = new Carousel(div); + const spy = vi.spyOn(carousel, 'goTo'); + carousel.resized(); + expect(spy).toHaveBeenCalledExactlyOnceWith(carousel.index); + }); +}); diff --git a/packages/tests/Carousel/CarouselBtn.spec.ts b/packages/tests/Carousel/CarouselBtn.spec.ts new file mode 100644 index 00000000..0fd0eb83 --- /dev/null +++ b/packages/tests/Carousel/CarouselBtn.spec.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, vi } from 'vitest'; +import { CarouselBtn, Carousel } from '@studiometa/ui'; +import { h } from '#test-utils'; + +describe('The CarouselBtn class', () => { + for (const [action, method] of [ + ['prev', 'goPrev'], + ['next', 'goNext'], + [2, 'goTo'], + ] as const) { + it('should dispatch its action to the carousel', async () => { + const btn = h('button', { dataOptionAction: action }); + const div = h('div', [btn]); + const carousel = new Carousel(div); + const carouselBtn = new CarouselBtn(btn); + + const spy = vi.spyOn(carousel, method); + spy.mockImplementation(() => {}); + carouselBtn.onClick(); + expect(spy).toHaveBeenCalledOnce(); + }); + } + + for (const [action, index, lastIndex, isDisabled] of [ + ['prev', 0, 10, true], + ['prev', 1, 10, false], + ['next', 1, 10, false], + ['next', 10, 10, true], + [1, 1, 10, true], + [1, 2, 10, false], + ] as const) { + it(`should set the disabled attribute to ${String(isDisabled)} when action is ${action}, index is ${index} and lastIndex is ${lastIndex}.`, async () => { + const btn = h('button', { dataOptionAction: action }); + const carouselBtn = new CarouselBtn(btn); + const spy = vi.spyOn(carouselBtn, 'carousel', 'get'); + // @ts-expect-error mock is partial + spy.mockImplementation(() => ({ index, lastIndex })); + carouselBtn.onParentCarouselProgress(); + expect(btn.disabled).toBe(isDisabled); + }); + } +}); diff --git a/packages/tests/Carousel/CarouselDrag.spec.ts b/packages/tests/Carousel/CarouselDrag.spec.ts new file mode 100644 index 00000000..516e4de9 --- /dev/null +++ b/packages/tests/Carousel/CarouselDrag.spec.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi } from 'vitest'; +import { CarouselDrag } from '@studiometa/ui'; +import { h, mount, useMatchMedia, wait } from '#test-utils'; + +describe('The CarouselDrag class', () => { + it('should mount only when pointer is fine', async () => { + const matchMedia = useMatchMedia(); + const div = h('div'); + const carouselDrag = new CarouselDrag(div); + const fn = vi.fn(); + carouselDrag.$on('mounted', fn); + await mount(carouselDrag); + expect(fn).not.toHaveBeenCalled(); + + matchMedia.useMediaQuery('(pointer: fine)'); + + await wait(10); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('should do nothing when not mounted', async () => { + const div = h('div'); + const carouselDrag = new CarouselDrag(div); + const spy = vi.spyOn(div, 'scrollTo'); + // @ts-expect-error partial mock + carouselDrag.dragged({}); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should do nothing for stop or inertia mode', async () => { + const div = h('div'); + const carouselDrag = new CarouselDrag(div); + vi.spyOn(carouselDrag, '$isMounted', 'get').mockImplementation(() => true); + const spy = vi.spyOn(div, 'scrollTo'); + // @ts-expect-error partial mock + carouselDrag.dragged({ mode: 'inertia' }); + expect(spy).not.toHaveBeenCalled(); + // @ts-expect-error partial mock + carouselDrag.dragged({ mode: 'stop' }); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should do nothing if no distance', async () => { + const div = h('div'); + const carouselDrag = new CarouselDrag(div); + + vi.spyOn(carouselDrag, '$isMounted', 'get').mockImplementation(() => true); + vi.spyOn(carouselDrag, 'isHorizontal', 'get').mockImplementation(() => true); + vi.spyOn(carouselDrag, 'isVertical', 'get').mockImplementation(() => false); + + const spy = vi.spyOn(div, 'scrollTo'); + + // @ts-expect-error partial mock + carouselDrag.dragged({ mode: 'drag', distance: { x: 0 } }); + expect(spy).not.toHaveBeenCalled(); + + vi.spyOn(carouselDrag, 'isHorizontal', 'get').mockImplementation(() => false); + vi.spyOn(carouselDrag, 'isVertical', 'get').mockImplementation(() => true); + + // @ts-expect-error partial mock + carouselDrag.dragged({ mode: 'drag', distance: { y: 0 } }); + expect(spy).not.toHaveBeenCalled(); + }); + + it('should scroll instantly when dragging', async () => { + const div = h('div'); + const carouselDrag = new CarouselDrag(div); + const spy = vi.spyOn(div, 'scrollTo'); + + vi.spyOn(carouselDrag, '$isMounted', 'get').mockImplementation(() => true); + vi.spyOn(carouselDrag, 'isHorizontal', 'get').mockImplementation(() => true); + vi.spyOn(carouselDrag, 'isVertical', 'get').mockImplementation(() => false); + + // @ts-expect-error partial mock + carouselDrag.dragged({ mode: 'drag', distance: { x: 1, y: 0 }, delta: { x: 1, y: 0 } }); + + expect(div.style.scrollSnapType).toBe('none'); + expect(spy).toHaveBeenCalledExactlyOnceWith({ + left: -1, + top: 0, + behavior: 'instant', + }); + }); + + it('should scroll to a snapped item on drop', async () => { + const div = h('div'); + const carouselDrag = new CarouselDrag(div); + const spy = vi.spyOn(div, 'scrollTo'); + + vi.spyOn(carouselDrag, '$isMounted', 'get').mockImplementation(() => true); + vi.spyOn(carouselDrag, 'isHorizontal', 'get').mockImplementation(() => true); + vi.spyOn(carouselDrag, 'isVertical', 'get').mockImplementation(() => false); + vi.spyOn(carouselDrag, 'carousel', 'get').mockImplementation(() => ({ + items: [ + { + // @ts-expect-error partial mock + state: { + left: 0, + top: 0, + }, + }, + { + // @ts-expect-error partial mock + state: { + left: -100, + top: -100, + }, + }, + ], + })); + + // @ts-expect-error partial mock + carouselDrag.dragged({ mode: 'drop', distance: { x: 10, y: 0 }, delta: { x: 10, y: 0 } }); + + expect(spy).toHaveBeenCalledExactlyOnceWith({ + left: -100, + behavior: 'smooth', + }); + spy.mockClear(); + + vi.spyOn(carouselDrag, 'isHorizontal', 'get').mockImplementation(() => false); + vi.spyOn(carouselDrag, 'isVertical', 'get').mockImplementation(() => true); + + // @ts-expect-error partial mock + carouselDrag.dragged({ mode: 'drop', distance: { x: 0, y: 10 }, delta: { x: 0, y: 10 } }); + + expect(spy).toHaveBeenCalledExactlyOnceWith({ + top: -100, + behavior: 'smooth', + }); + div.dispatchEvent(new Event('scrollend')); + expect(div.style.scrollSnapType).toBe(''); + }); +}); diff --git a/packages/tests/Carousel/CarouselItem.spec.ts b/packages/tests/Carousel/CarouselItem.spec.ts new file mode 100644 index 00000000..51ff25b0 --- /dev/null +++ b/packages/tests/Carousel/CarouselItem.spec.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi } from 'vitest'; +import { CarouselItem, Carousel } from '@studiometa/ui'; +import { getInstanceFromElement } from '@studiometa/js-toolkit'; +import { h, mount, wait } from '#test-utils'; + +describe('The CarouselItem class', () => { + it('should know its own index', async () => { + const items = [ + h('div', { dataComponent: 'CarouselItem' }), + h('div', { dataComponent: 'CarouselItem' }), + h('div', { dataComponent: 'CarouselItem' }), + h('div', { dataComponent: 'CarouselItem' }), + ]; + const wrapper = h('div', { dataComponent: 'CarouselWrapper' }, items); + const div = h('div', [wrapper]); + const carousel = new Carousel(div); + await mount(carousel); + + const firstItem = getInstanceFromElement(items.at(0), CarouselItem); + const secondItem = getInstanceFromElement(items.at(1), CarouselItem); + + expect(firstItem.index).toBe(0); + expect(secondItem.index).toBe(1); + }); + + it('should set an active state when active', async () => { + const div = h('div'); + const carouselItem = new CarouselItem(div); + vi.spyOn(carouselItem, 'index', 'get').mockImplementation(() => 0); + // @ts-expect-error partial mock + vi.spyOn(carouselItem, 'carousel', 'get').mockImplementation(() => ({ index: 0 })); + + carouselItem.onParentCarouselProgress(); + await wait(20); + expect(div.style.getPropertyValue('--carousel-item-active')).toBe('1'); + + // @ts-expect-error partial mock + vi.spyOn(carouselItem, 'carousel', 'get').mockImplementation(() => ({ index: 1 })); + carouselItem.onParentCarouselProgress(); + expect(div.style.getPropertyValue('--carousel-item-active')).toBe('1'); + await wait(20); + expect(div.style.getPropertyValue('--carousel-item-active')).toBe('0'); + }); +}); diff --git a/packages/tests/Carousel/CarouselProgress.spec.ts b/packages/tests/Carousel/CarouselProgress.spec.ts new file mode 100644 index 00000000..9d4bf9a2 --- /dev/null +++ b/packages/tests/Carousel/CarouselProgress.spec.ts @@ -0,0 +1,14 @@ +import { describe, it, expect, vi } from 'vitest'; +import { CarouselProgress } from '@studiometa/ui'; +import { h } from '#test-utils'; + +describe('The AbstractCarouselChild class', () => { + it('should not mount if it can not find a parent Carousel', async () => { + const div = h('div'); + const carouselProgress = new CarouselProgress(div); + const spy = vi.spyOn(carouselProgress, 'carousel', 'get'); + spy.mockImplementation(() => ({ progress: 1 })); + carouselProgress.onParentCarouselProgress(); + expect(div.style.getPropertyValue('--carousel-progress')).toBe('1'); + }); +}); diff --git a/packages/tests/Carousel/CarouselWrapper.spec.ts b/packages/tests/Carousel/CarouselWrapper.spec.ts new file mode 100644 index 00000000..7104efe5 --- /dev/null +++ b/packages/tests/Carousel/CarouselWrapper.spec.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi } from 'vitest'; +import { CarouselWrapper } from '@studiometa/ui'; +import { h } from '#test-utils'; + +describe('The CarouselWrapper class', () => { + it('should return its progress', async () => { + const div = h('div'); + const carouselWrapper = new CarouselWrapper(div); + + vi.spyOn(carouselWrapper, 'isHorizontal', 'get').mockImplementation(() => false); + vi.spyOn(carouselWrapper, 'isVertical', 'get').mockImplementation(() => false); + + expect(carouselWrapper.progress).toBe(0); + + // Horizontal but no scroll + vi.spyOn(carouselWrapper, 'isHorizontal', 'get').mockImplementation(() => true); + vi.spyOn(carouselWrapper, 'isVertical', 'get').mockImplementation(() => false); + expect(carouselWrapper.progress).toBe(0); + + // Vertical but no scroll + vi.spyOn(carouselWrapper, 'isHorizontal', 'get').mockImplementation(() => false); + vi.spyOn(carouselWrapper, 'isVertical', 'get').mockImplementation(() => true); + expect(carouselWrapper.progress).toBe(0); + + // Horizontal, size and scrollable, no scroll + vi.spyOn(div, 'scrollWidth', 'get').mockImplementation(() => 100); + vi.spyOn(div, 'offsetWidth', 'get').mockImplementation(() => 50); + vi.spyOn(carouselWrapper, 'isHorizontal', 'get').mockImplementation(() => true); + vi.spyOn(carouselWrapper, 'isVertical', 'get').mockImplementation(() => false); + expect(carouselWrapper.progress).toBe(0); + + // Horizontal, size and scrollable and scroll + vi.spyOn(div, 'scrollLeft', 'get').mockImplementation(() => 25); + expect(carouselWrapper.progress).toBe(0.5); + + // Vertical, size and scrollable, no scroll + vi.spyOn(div, 'scrollHeight', 'get').mockImplementation(() => 100); + vi.spyOn(div, 'offsetHeight', 'get').mockImplementation(() => 50); + vi.spyOn(carouselWrapper, 'isHorizontal', 'get').mockImplementation(() => false); + vi.spyOn(carouselWrapper, 'isVertical', 'get').mockImplementation(() => true); + expect(carouselWrapper.progress).toBe(0); + + // Horizontal, size and scrollable and scroll + vi.spyOn(div, 'scrollTop', 'get').mockImplementation(() => 25); + expect(carouselWrapper.progress).toBe(0.5); + }); + + it('should update index when scrolling', () => { + const div = h('div'); + const carouselWrapper = new CarouselWrapper(div); + const mock = { + index: 0, + $emit: vi.fn(), + items: [ + { + state: { + left: 0, + top: 0, + }, + }, + { + state: { + left: -100, + top: -100, + }, + }, + ], + }; + const carousel = vi.spyOn(carouselWrapper, 'carousel', 'get'); + // @ts-expect-error partial mock + carousel.mockImplementation(() => mock); + + vi.spyOn(carouselWrapper, 'isHorizontal', 'get').mockImplementation(() => true); + vi.spyOn(carouselWrapper, 'isVertical', 'get').mockImplementation(() => false); + vi.spyOn(div, 'scrollLeft', 'get').mockImplementation(() => 10); + vi.spyOn(div, 'scrollTop', 'get').mockImplementation(() => 10); + + carouselWrapper.onScroll(); + + expect(mock.index).toBe(0); + expect(mock.$emit).toHaveBeenCalledExactlyOnceWith('progress', 0); + + vi.spyOn(div, 'scrollLeft', 'get').mockImplementation(() => -100); + vi.spyOn(div, 'scrollTop', 'get').mockImplementation(() => -100); + carouselWrapper.onScroll(); + + expect(mock.index).toBe(1); + + vi.spyOn(carouselWrapper, 'isHorizontal', 'get').mockImplementation(() => false); + vi.spyOn(carouselWrapper, 'isVertical', 'get').mockImplementation(() => true); + vi.spyOn(div, 'scrollLeft', 'get').mockImplementation(() => -90); + vi.spyOn(div, 'scrollTop', 'get').mockImplementation(() => -90); + carouselWrapper.onScroll(); + + expect(mock.index).toBe(1); + }); + + it('should scroll to the matching item when the carousel goes to', async () => { + const div = h('div'); + const carouselWrapper = new CarouselWrapper(div); + const mock = { + index: 0, + items: [ + { + state: { + left: 0, + top: 0, + }, + }, + { + state: { + left: -100, + top: -100, + }, + }, + ], + }; + const carousel = vi.spyOn(carouselWrapper, 'carousel', 'get'); + // @ts-expect-error partial mock + carousel.mockImplementation(() => mock); + + const spy = vi.spyOn(div, 'scrollTo'); + carouselWrapper.onParentCarouselGoTo(); + expect(spy).toHaveBeenCalledExactlyOnceWith({ + left: 0, + top: 0, + behavior: 'smooth', + }); + spy.mockClear(); + + mock.index = 1; + + carouselWrapper.onParentCarouselGoTo(); + expect(spy).toHaveBeenCalledExactlyOnceWith({ + left: -100, + top: -100, + behavior: 'smooth', + }); + }); +}); diff --git a/packages/tests/Carousel/utils.spec.ts b/packages/tests/Carousel/utils.spec.ts new file mode 100644 index 00000000..b492175d --- /dev/null +++ b/packages/tests/Carousel/utils.spec.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest'; +import { getClosestIndex } from '#private/Carousel/utils.js'; + +describe('The getClosestIndex function', () => { + it('should return the closest index', () => { + const fixtures = [ + [[0, 1, 2, 3] as number[], 1, 1], + [[0.1, 0.2, 0.3, 0.4] as number[], 0.222, 1], + ] as const; + + for (const [numbers, target, result] of fixtures) { + expect(getClosestIndex(numbers, target)).toBe(result); + } + }); +}); diff --git a/packages/tests/__utils__/index.ts b/packages/tests/__utils__/index.ts index 44e8d6d7..50353e69 100644 --- a/packages/tests/__utils__/index.ts +++ b/packages/tests/__utils__/index.ts @@ -2,6 +2,7 @@ export * from './components.js'; export * from './faketimers.js'; export * from './h.js'; export * from './lifecycle.js'; +export * from './matchMedia.js'; export * from './mockImageLoad.js'; export * from './mockIntersectionObserver.js'; export * from './resizeWindow.js'; diff --git a/packages/tests/__utils__/matchMedia.ts b/packages/tests/__utils__/matchMedia.ts new file mode 100644 index 00000000..030b05db --- /dev/null +++ b/packages/tests/__utils__/matchMedia.ts @@ -0,0 +1,127 @@ +import { afterEach, vi } from 'vitest'; + +class MatchMedia { + mediaQueries = {}; + mediaQueryList = {}; + currentMediaQuery = ''; + constructor() { + this.mediaQueries = {}; + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: (query) => { + this.mediaQueryList = { + matches: query === this.currentMediaQuery, + media: query, + onchange: null, + addListener: (listener) => { + this.addListener(query, listener); + }, + removeListener: (listener) => { + this.removeListener(query, listener); + }, + addEventListener: (type, listener) => { + if (type !== 'change') return; + this.addListener(query, listener); + }, + removeEventListener: (type, listener) => { + if (type !== 'change') return; + this.removeListener(query, listener); + }, + dispatchEvent: vi.fn(), + }; + return this.mediaQueryList; + }, + }); + } + /** + * Adds a new listener function for the specified media query + * @private + */ + addListener(mediaQuery, listener) { + if (!this.mediaQueries[mediaQuery]) { + this.mediaQueries[mediaQuery] = []; + } + const query = this.mediaQueries[mediaQuery]; + const listenerIndex = query.indexOf(listener); + if (listenerIndex !== -1) return; + query.push(listener); + } + /** + * Removes a previously added listener function for the specified media query + * @private + */ + removeListener(mediaQuery, listener) { + if (!this.mediaQueries[mediaQuery]) return; + const query = this.mediaQueries[mediaQuery]; + const listenerIndex = query.indexOf(listener); + if (listenerIndex === -1) return; + query.splice(listenerIndex, 1); + } + /** + * Updates the currently used media query, + * and calls previously added listener functions registered for this media query + * @public + */ + useMediaQuery(mediaQuery) { + if (typeof mediaQuery !== 'string') throw new Error('Media Query must be a string'); + this.currentMediaQuery = mediaQuery; + if (!this.mediaQueries[mediaQuery]) return; + const mqListEvent = { + matches: true, + media: mediaQuery, + }; + this.mediaQueries[mediaQuery].forEach((listener) => { + listener.call(this.mediaQueryList, mqListEvent); + }); + } + /** + * Returns an array listing the media queries for which the matchMedia has registered listeners + * @public + */ + getMediaQueries() { + return Object.keys(this.mediaQueries); + } + /** + * Returns a copy of the array of listeners for the specified media query + * @public + */ + getListeners(mediaQuery) { + if (!this.mediaQueries[mediaQuery]) return []; + return this.mediaQueries[mediaQuery].slice(); + } + /** + * Clears all registered media queries and their listeners + * @public + */ + clear() { + this.mediaQueries = {}; + } + /** + * Clears all registered media queries and their listeners, + * and destroys the implementation of `window.matchMedia` + * @public + */ + destroy() { + this.clear(); + delete window.matchMedia; + } +} + +const defaultMediaQuery = '(min-width: 80rem)'; +// @ts-ignore +const matchMedia = new MatchMedia(); + +export function useMatchMedia(mediaQuery = defaultMediaQuery) { + matchMedia.useMediaQuery(mediaQuery); + + return matchMedia; +} + +export function resetMatchMedia() { + matchMedia.useMediaQuery(defaultMediaQuery); +} + +afterEach(() => { + resetMatchMedia(); +}); diff --git a/packages/tests/index.spec.ts b/packages/tests/index.spec.ts index 0f83274b..672e15d8 100644 --- a/packages/tests/index.spec.ts +++ b/packages/tests/index.spec.ts @@ -4,6 +4,7 @@ import * as components from '@studiometa/ui'; test('components exports', () => { expect(Object.keys(components).toSorted()).toMatchInlineSnapshot(` [ + "AbstractCarouselChild", "AbstractFrameTrigger", "AbstractPrefetch", "AbstractScrollAnimation", @@ -15,6 +16,12 @@ test('components exports', () => { "AnchorNavLink", "AnchorNavTarget", "AnchorScrollTo", + "Carousel", + "CarouselBtn", + "CarouselDrag", + "CarouselItem", + "CarouselProgress", + "CarouselWrapper", "CircularMarquee", "Cursor", "DataBind", From 295381675a3e628ff5abccae33d14fce58b7d2d4 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Tue, 24 Jun 2025 16:20:51 +0200 Subject: [PATCH 04/16] Fix implementation --- packages/ui/Carousel/AbstractCarouselChild.ts | 12 ++++++----- packages/ui/Carousel/Carousel.ts | 17 +++++++++++++-- packages/ui/Carousel/CarouselItem.ts | 21 +++++++++++-------- packages/ui/Carousel/CarouselWrapper.ts | 2 +- 4 files changed, 35 insertions(+), 17 deletions(-) diff --git a/packages/ui/Carousel/AbstractCarouselChild.ts b/packages/ui/Carousel/AbstractCarouselChild.ts index a1e7281d..f6a883bb 100644 --- a/packages/ui/Carousel/AbstractCarouselChild.ts +++ b/packages/ui/Carousel/AbstractCarouselChild.ts @@ -52,21 +52,23 @@ export class AbstractCarouselChild extends Base * Mounted hook. */ mounted() { - if (!this.carousel) { + const { carousel } = this; + + if (!carousel) { this.$warn('Could not find a parent slider, not mounting.'); this.$destroy(); return; } - this.carousel.$on('go-to', this); - this.carousel.$on('progress', this); + carousel.$on('go-to', this); + carousel.$on('progress', this); } /** * Destroyed hook. */ destroyed() { - this.carousel?.$off('go-to', this); - this.carousel?.$off('progress', this); + this.carousel?.$off?.('go-to', this); + this.carousel?.$off?.('progress', this); } } diff --git a/packages/ui/Carousel/Carousel.ts b/packages/ui/Carousel/Carousel.ts index 5ca17a39..8bd37f8f 100644 --- a/packages/ui/Carousel/Carousel.ts +++ b/packages/ui/Carousel/Carousel.ts @@ -30,7 +30,7 @@ export class Carousel extends Base extends Base extends Base extends AbstractCarou return state; } - timer: number; - /** * Update the item's state on parent carousel progress. * @todo a11y */ onParentCarouselProgress() { - window.clearTimeout(this.timer); - this.timer = window.setTimeout(() => { - this.$el.style.setProperty( - '--carousel-item-active', - String(Number(this.index === this.carousel.index)), - ); - }, 16); + domScheduler.read(() => { + const { index } = this; + const { index: carouselIndex } = this.carousel; + + domScheduler.write(() => { + this.$el.style.setProperty( + '--carousel-item-active', + String(Number(index === carouselIndex)), + ); + }); + }); } } diff --git a/packages/ui/Carousel/CarouselWrapper.ts b/packages/ui/Carousel/CarouselWrapper.ts index 53a2d9fa..cd364507 100644 --- a/packages/ui/Carousel/CarouselWrapper.ts +++ b/packages/ui/Carousel/CarouselWrapper.ts @@ -47,7 +47,7 @@ export class CarouselWrapper extends AbstractCa ); carousel.index = minDiffIndex; - carousel.$emit('progress', this.progress); + this.carousel.$services.enable('ticked'); } /** From d2b70ede4086334e609a458b0745301bbf0ff816 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Tue, 24 Jun 2025 16:20:58 +0200 Subject: [PATCH 05/16] Update docs --- packages/docs/components/Carousel/stories/horizontal/app.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/components/Carousel/stories/horizontal/app.twig b/packages/docs/components/Carousel/stories/horizontal/app.twig index 3cbdfa84..974bf7ec 100644 --- a/packages/docs/components/Carousel/stories/horizontal/app.twig +++ b/packages/docs/components/Carousel/stories/horizontal/app.twig @@ -10,7 +10,7 @@ {% endfor %} -
+
diff --git a/packages/docs/components/Carousel/stories/vertical/app.twig b/packages/docs/components/Carousel/stories/vertical/app.twig index 75fe5200..f1dd982f 100644 --- a/packages/docs/components/Carousel/stories/vertical/app.twig +++ b/packages/docs/components/Carousel/stories/vertical/app.twig @@ -25,8 +25,7 @@
{% endfor %} -
+
From dd5c8696a742f2c5bf62f36e4ba890727c9f053e Mon Sep 17 00:00:00 2001 From: Antoine Quatrelivre Date: Fri, 25 Jul 2025 20:21:37 +0200 Subject: [PATCH 12/16] Add an Indexable primitive component and a withIndex decorator --- packages/tests/Indexable/Indexable.spec.ts | 157 ++++++++++++ packages/tests/index.spec.ts | 2 + packages/ui/Indexable/Indexable.ts | 14 ++ packages/ui/Indexable/index.ts | 1 + packages/ui/decorators/index.ts | 1 + packages/ui/decorators/withIndex.ts | 264 +++++++++++++++++++++ packages/ui/index.ts | 1 + 7 files changed, 440 insertions(+) create mode 100644 packages/tests/Indexable/Indexable.spec.ts create mode 100644 packages/ui/Indexable/Indexable.ts create mode 100644 packages/ui/Indexable/index.ts create mode 100644 packages/ui/decorators/withIndex.ts diff --git a/packages/tests/Indexable/Indexable.spec.ts b/packages/tests/Indexable/Indexable.spec.ts new file mode 100644 index 00000000..d435b4ee --- /dev/null +++ b/packages/tests/Indexable/Indexable.spec.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Indexable } from '@studiometa/ui'; +import { h } from '#test-utils'; + +class TestIndexable extends Indexable { + #length = 3; + + get length() { + return this.#length; + } + + set length(value: number) { + this.#length = value; + } +} + +describe('The Indexable class', () => { + let indexable: TestIndexable; + let element: HTMLElement; + + beforeEach(() => { + element = h('div'); + indexable = new TestIndexable(element); + }); + + describe(`"${Indexable.MODES.NORMAL}" mode`, () => { + beforeEach(() => { + indexable.mode = Indexable.MODES.NORMAL; + }); + + it('should stay in bounds indexes', () => { + indexable.currentIndex = 0; + expect(indexable.nextIndex).toBe(1); + expect(indexable.prevIndex).toBe(0); + + indexable.currentIndex = 2; + expect(indexable.nextIndex).toBe(2); + expect(indexable.prevIndex).toBe(1); + }); + }); + + describe(`"${Indexable.MODES.INFINITE}" mode`, () => { + beforeEach(() => { + indexable.mode = Indexable.MODES.INFINITE; + }); + + it('should wrap around indexes', () => { + indexable.currentIndex = 0; + expect(indexable.nextIndex).toBe(1); + expect(indexable.prevIndex).toBe(2); // (0 - 1 + 3) % 3 = 2 + + indexable.currentIndex = 2; + expect(indexable.nextIndex).toBe(0); // (2 + 1) % 3 = 0 + expect(indexable.prevIndex).toBe(1); + }); + + it('should handle out of bounds indexes', () => { + indexable.currentIndex = -1; + expect(indexable.currentIndex).toBe(2); // (-1 + 3) % 3 = 2 + + indexable.currentIndex = 5; + expect(indexable.currentIndex).toBe(2); // (5) % 3 = 2 + }); + }); + + describe(`"${Indexable.MODES.ALTERNATE}" mode`, () => { + beforeEach(() => { + indexable.mode = Indexable.MODES.ALTERNATE; + }); + + it('should alternate direction when reaching bounds', () => { + indexable.currentIndex = 0; + expect(indexable.nextIndex).toBe(1); + expect(indexable.prevIndex).toBe(1); + + indexable.currentIndex = 2; + expect(indexable.nextIndex).toBe(1); + expect(indexable.prevIndex).toBe(1); + }); + + it('should handle out of bounds indexes', () => { + indexable.currentIndex = -1; + expect(indexable.currentIndex).toBe(1); + + indexable.currentIndex = 5; + expect(indexable.currentIndex).toBe(1); + }); + }); + + describe('goTo method', () => { + it('should go to specific index', async () => { + const emitSpy = vi.spyOn(indexable, '$emit'); + + await indexable.goTo(1); + expect(indexable.currentIndex).toBe(1); + expect(emitSpy).toHaveBeenCalledWith('index', 1); + }); + + it(`should handle "${Indexable.INSTRUCTIONS.NEXT}" instruction`, async () => { + await indexable.goTo(Indexable.INSTRUCTIONS.NEXT); + expect(indexable.currentIndex).toBe(1); + }); + + it(`should handle "${Indexable.INSTRUCTIONS.PREVIOUS}" instruction`, async () => { + indexable.currentIndex = 1; + await indexable.goTo(Indexable.INSTRUCTIONS.PREVIOUS); + expect(indexable.currentIndex).toBe(0); + }); + + it(`should handle "${Indexable.INSTRUCTIONS.FIRST}" instruction`, async () => { + indexable.currentIndex = 2; + await indexable.goTo(Indexable.INSTRUCTIONS.FIRST); + expect(indexable.currentIndex).toBe(0); + }); + + it(`should handle "${Indexable.INSTRUCTIONS.LAST}" instruction`, async () => { + await indexable.goTo(Indexable.INSTRUCTIONS.LAST); + expect(indexable.currentIndex).toBe(2); + }); + + it(`should handle "${Indexable.INSTRUCTIONS.RANDOM}" instruction`, async () => { + const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0.5); + + await indexable.goTo(Indexable.INSTRUCTIONS.RANDOM); + expect(indexable.currentIndex).toBeGreaterThanOrEqual(0); + expect(indexable.currentIndex).toBeLessThanOrEqual(2); + + randomSpy.mockRestore(); + }); + + it('should handle reverse with instructions', async () => { + indexable.isReverse = true; + + indexable.currentIndex = 1; + await indexable.goTo(Indexable.INSTRUCTIONS.PREVIOUS); + expect(indexable.currentIndex).toBe(2); + + indexable.currentIndex = 1; + await indexable.goTo(Indexable.INSTRUCTIONS.NEXT); + expect(indexable.currentIndex).toBe(0); + + await indexable.goTo(Indexable.INSTRUCTIONS.FIRST); + expect(indexable.currentIndex).toBe(2); + + await indexable.goTo(Indexable.INSTRUCTIONS.LAST); + expect(indexable.currentIndex).toBe(0); + }); + + it('should reject invalid instruction', async () => { + const warnSpy = vi.spyOn(indexable, '$warn'); + + await expect(indexable.goTo('invalid' as any)).rejects.toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith('Invalid goto instruction.'); + expect(indexable.currentIndex).toBe(0); + }); + }); +}); diff --git a/packages/tests/index.spec.ts b/packages/tests/index.spec.ts index 89c7af19..40433017 100644 --- a/packages/tests/index.spec.ts +++ b/packages/tests/index.spec.ts @@ -40,6 +40,7 @@ test('components exports', () => { "FrameTarget", "FrameTriggerLoader", "Hoverable", + "Indexable", "LargeText", "LazyInclude", "Menu", @@ -70,6 +71,7 @@ test('components exports', () => { "Transition", "animationScrollWithEase", "withDeprecation", + "withIndex", "withTransition", ] `); diff --git a/packages/ui/Indexable/Indexable.ts b/packages/ui/Indexable/Indexable.ts new file mode 100644 index 00000000..d4abb865 --- /dev/null +++ b/packages/ui/Indexable/Indexable.ts @@ -0,0 +1,14 @@ +import { Base, BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import { withIndex } from '../decorators/index.js'; + +/** + * Indexable class. + */ +export class Indexable extends withIndex(Base) { + /** + * Config. + */ + static config: BaseConfig = { + name: 'Indexable', + }; +} diff --git a/packages/ui/Indexable/index.ts b/packages/ui/Indexable/index.ts new file mode 100644 index 00000000..d7c1acbf --- /dev/null +++ b/packages/ui/Indexable/index.ts @@ -0,0 +1 @@ +export * from './Indexable.js'; diff --git a/packages/ui/decorators/index.ts b/packages/ui/decorators/index.ts index c1c2cc82..47cfa628 100644 --- a/packages/ui/decorators/index.ts +++ b/packages/ui/decorators/index.ts @@ -1,2 +1,3 @@ export * from './withTransition.js'; export * from './withDeprecation.js'; +export * from './withIndex.js'; diff --git a/packages/ui/decorators/withIndex.ts b/packages/ui/decorators/withIndex.ts new file mode 100644 index 00000000..85b03a22 --- /dev/null +++ b/packages/ui/decorators/withIndex.ts @@ -0,0 +1,264 @@ +import type { + Base, + BaseDecorator, + BaseProps, + BaseConfig, + BaseInterface, +} from '@studiometa/js-toolkit'; +import { clamp, isString, randomInt } from '@studiometa/js-toolkit/utils'; + +const INDEXABLE_MODES = { + NORMAL: 'normal', + INFINITE: 'infinite', + ALTERNATE: 'alternate', +} as const; + +export type IndexableMode = typeof INDEXABLE_MODES[keyof typeof INDEXABLE_MODES]; + +const INDEXABLE_INSTRUCTIONS = { + NEXT: 'next', + PREVIOUS: 'previous', + FIRST: 'first', + LAST: 'last', + RANDOM: 'random', +} as const; + +export type IndexableInstructions = typeof INDEXABLE_INSTRUCTIONS[keyof typeof INDEXABLE_INSTRUCTIONS]; + +export interface IndexableProps extends BaseProps { + $options: { + mode: IndexableMode; + reverse: boolean; + }; +} + +export interface IndexableInterface extends BaseInterface { + /** + * Index storage. + */ + __index: number; + + /** + * Is reverse ? + */ + get isReverse(): boolean; + set isReverse(value: boolean); + + /** + * Get mode. + */ + get mode(): IndexableMode; + set mode(value: IndexableMode); + + /** + * Get the length. + */ + get length(): number; + + /** + * Get the minimum index. + */ + get minIndex(): number; + + /** + * Get the maximum index. + */ + get maxIndex(): number; + + /** + * Get the current index. + */ + get currentIndex(): number; + set currentIndex(value: number); + + /** + * Get the first index. + */ + get firstIndex(): number; + + /** + * Get the last index. + */ + get lastIndex(): number; + + /** + * Get the previous index. + */ + get prevIndex(): number; + + /** + * Get the next index. + */ + get nextIndex(): number; + + /** + * Go to the specified index or instruction. + */ + goTo(indexOrInstruction?: number | IndexableInstructions): Promise; + + /** + * Go to the next index. + */ + goNext(): Promise; + + /** + * Go to the previous index. + */ + goPrev(): Promise; +} + +/** + * Extend a class to add index management. + */ +export function withIndex( + BaseClass: typeof Base, +): BaseDecorator & { + MODES: typeof INDEXABLE_MODES; + INSTRUCTIONS: typeof INDEXABLE_INSTRUCTIONS; +} { + /** + * Class. + */ + class Indexable extends BaseClass { + /** + * Config. + */ + static config: BaseConfig = { + ...BaseClass.config, + emits: ['index'], + options: { + mode: { + type: String, + default: INDEXABLE_MODES.NORMAL, + }, + reverse: Boolean, + }, + }; + + __index = 0; + + get isReverse() { + return this.$options.reverse === true; + } + + set isReverse(value) { + this.$options.reverse = !!value; + } + + get mode() { + const { mode } = this.$options; + + if (!Object.values(INDEXABLE_MODES).includes(mode)) { + return INDEXABLE_MODES.NORMAL; + } + + return mode; + } + + set mode(value) { + this.$options.mode = Object.values(INDEXABLE_MODES).includes(value) ? value : INDEXABLE_MODES.NORMAL; + } + + get length() { + this.$warn('The length property should be overridden to match with the actual number of items. Finite length is required for infinite and alternate modes.'); + return Number.POSITIVE_INFINITY; + } + + get minIndex() { + return 0; + } + + get maxIndex() { + return this.length - 1; + } + + get currentIndex() { + return this.__index; + } + + set currentIndex(value) { + switch (this.mode) { + case INDEXABLE_MODES.ALTERNATE: + if (Math.floor(value/this.length) % 2 !== 0) { + this.isReverse = !this.isReverse; + } + const cycleLength = this.length * 2; + const cycleIndex = Math.abs(value) % cycleLength; + this.__index = Math.min(cycleIndex, cycleLength - cycleIndex); + break; + case INDEXABLE_MODES.INFINITE: + this.__index = (value + this.length) % this.length + break; + case INDEXABLE_MODES.NORMAL: + default: + this.__index = clamp(value, this.minIndex, this.maxIndex); + break; + } + this.$emit('index', this.currentIndex); + } + + get firstIndex() { + return this.isReverse ? this.maxIndex : this.minIndex; + } + + get lastIndex() { + return this.isReverse ? this.minIndex : this.maxIndex; + } + + get prevIndex() { + let rawIndex = this.isReverse ? this.currentIndex + 1 : this.currentIndex - 1; + if (this.mode === INDEXABLE_MODES.ALTERNATE && (rawIndex > this.maxIndex || rawIndex < this.minIndex)) { + this.isReverse = !this.isReverse; + rawIndex = this.isReverse ? this.currentIndex + 1 : this.currentIndex - 1; + } + return this.mode === INDEXABLE_MODES.NORMAL ? clamp(rawIndex, this.minIndex, this.maxIndex) : (rawIndex + this.length) % this.length; + } + + get nextIndex() { + let rawIndex = this.isReverse ? this.currentIndex - 1 : this.currentIndex + 1; + if (this.mode === INDEXABLE_MODES.ALTERNATE && (rawIndex > this.maxIndex || rawIndex < this.minIndex)) { + this.isReverse = !this.isReverse; + rawIndex = this.isReverse ? this.currentIndex - 1 : this.currentIndex + 1; + } + return this.mode === INDEXABLE_MODES.NORMAL ? clamp(rawIndex, this.minIndex, this.maxIndex) : (rawIndex + this.length) % this.length; + } + + goTo(indexOrInstruction) { + if (isString(indexOrInstruction)) { + switch (indexOrInstruction) { + case INDEXABLE_INSTRUCTIONS.NEXT: + return this.goTo(this.nextIndex); + case INDEXABLE_INSTRUCTIONS.PREVIOUS: + return this.goTo(this.prevIndex); + case INDEXABLE_INSTRUCTIONS.FIRST: + return this.goTo(this.firstIndex); + case INDEXABLE_INSTRUCTIONS.LAST: + return this.goTo(this.lastIndex); + case INDEXABLE_INSTRUCTIONS.RANDOM: + // @TODO: eventually store previous indexes to avoid duplicates + return this.goTo(randomInt(this.minIndex, this.maxIndex)); + default: + this.$warn('Invalid goto instruction.'); + return Promise.reject(); + } + } + this.currentIndex = indexOrInstruction; + return Promise.resolve(); + } + + goNext() { + return this.goTo(this.nextIndex); + } + + goPrev() { + return this.goTo(this.prevIndex); + } + } + + // Add constants as static properties to the returned class + const IndexableWithConstants = Indexable as any; + IndexableWithConstants.MODES = INDEXABLE_MODES; + IndexableWithConstants.INSTRUCTIONS = INDEXABLE_INSTRUCTIONS; + + return IndexableWithConstants; +} diff --git a/packages/ui/index.ts b/packages/ui/index.ts index 14a69762..0f972c3b 100644 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -11,6 +11,7 @@ export * from './Draggable/index.js'; export * from './Figure/index.js'; export * from './FigureVideo/index.js'; export * from './Frame/index.js'; +export * from './Indexable/index.js'; export * from './Hoverable/index.js'; export * from './LargeText/index.js'; export * from './LazyInclude/index.js'; From 201446a9720e76f2cb0f032d99e98f8a09b7eeca Mon Sep 17 00:00:00 2001 From: Antoine Quatrelivre Date: Fri, 25 Jul 2025 21:48:29 +0200 Subject: [PATCH 13/16] Use Indexable for the Carousel --- .../Carousel/AbstractCarouselChild.spec.ts | 4 +- packages/tests/Carousel/Carousel.spec.ts | 32 +++++--- packages/tests/Carousel/CarouselBtn.spec.ts | 4 +- packages/tests/Carousel/CarouselItem.spec.ts | 4 +- .../tests/Carousel/CarouselWrapper.spec.ts | 16 ++-- packages/ui/Carousel/AbstractCarouselChild.ts | 8 +- packages/ui/Carousel/Carousel.ts | 82 ++++--------------- packages/ui/Carousel/CarouselBtn.ts | 8 +- packages/ui/Carousel/CarouselItem.ts | 2 +- packages/ui/Carousel/CarouselWrapper.ts | 6 +- packages/ui/Indexable/Indexable.ts | 1 + 11 files changed, 64 insertions(+), 103 deletions(-) diff --git a/packages/tests/Carousel/AbstractCarouselChild.spec.ts b/packages/tests/Carousel/AbstractCarouselChild.spec.ts index 17f1a321..71a7c787 100644 --- a/packages/tests/Carousel/AbstractCarouselChild.spec.ts +++ b/packages/tests/Carousel/AbstractCarouselChild.spec.ts @@ -19,7 +19,7 @@ describe('The AbstractCarouselChild class', () => { expect(child.carousel).toBe(carousel); const spy = vi.spyOn(child, '$emit'); - for (const eventName of ['go-to', 'progress']) { + for (const eventName of ['index', 'progress']) { carousel.$emit(eventName, 0); expect(spy).toHaveBeenCalledExactlyOnceWith(`parent-carousel-${eventName}`, 0); spy.mockClear(); @@ -28,7 +28,7 @@ describe('The AbstractCarouselChild class', () => { await destroy(child); spy.mockClear(); - for (const eventName of ['go-to', 'progress']) { + for (const eventName of ['index', 'progress']) { carousel.$emit(eventName, 0); expect(spy).not.toHaveBeenCalled(); spy.mockClear(); diff --git a/packages/tests/Carousel/Carousel.spec.ts b/packages/tests/Carousel/Carousel.spec.ts index f2cb9be0..593264c1 100644 --- a/packages/tests/Carousel/Carousel.spec.ts +++ b/packages/tests/Carousel/Carousel.spec.ts @@ -14,21 +14,27 @@ describe('The Carousel class', () => { expect(carousel.isVertical).toBe(true); }); - it('should emit go-to and progress events', async () => { - const div = h('div'); + it('should emit index and progress events', async () => { + const items = [ + h('div', { dataComponent: 'CarouselItem' }), + h('div', { dataComponent: 'CarouselItem' }), + ]; + const wrapper = h('div', { dataComponent: 'CarouselWrapper' }, items); + const div = h('div', [wrapper]); const carousel = new Carousel(div); - const goToFn = vi.fn(); + await mount(carousel); + const indexFn = vi.fn(); const progressFn = vi.fn(); - carousel.$on('go-to', goToFn); + carousel.$on('index', indexFn); carousel.$on('progress', progressFn); carousel.goTo(0); - expect(goToFn).toHaveBeenCalledOnce(); - expect(goToFn.mock.lastCall[0].detail).toEqual([0]); + expect(indexFn).toHaveBeenCalledOnce(); + expect(indexFn.mock.lastCall[0].detail).toEqual([0]); await wait(); expect(progressFn).toHaveBeenCalledOnce(); progressFn.mockClear(); carousel.goTo(1); - expect(goToFn.mock.lastCall[0].detail).toEqual([1]); + expect(indexFn.mock.lastCall[0].detail).toEqual([1]); }); it('should implement an indexable API', async () => { @@ -43,28 +49,28 @@ describe('The Carousel class', () => { const carousel = new Carousel(div); await mount(carousel); - expect(carousel.index).toBe(0); + expect(carousel.currentIndex).toBe(0); expect(carousel.prevIndex).toBe(0); expect(carousel.nextIndex).toBe(1); expect(carousel.lastIndex).toBe(3); carousel.goTo(1); - expect(carousel.index).toBe(1); + expect(carousel.currentIndex).toBe(1); expect(carousel.prevIndex).toBe(0); expect(carousel.nextIndex).toBe(2); expect(carousel.lastIndex).toBe(3); carousel.goNext(); - expect(carousel.index).toBe(2); + expect(carousel.currentIndex).toBe(2); expect(carousel.prevIndex).toBe(1); expect(carousel.nextIndex).toBe(3); expect(carousel.lastIndex).toBe(3); carousel.goPrev(); - expect(carousel.index).toBe(1); + expect(carousel.currentIndex).toBe(1); expect(carousel.prevIndex).toBe(0); expect(carousel.nextIndex).toBe(2); expect(carousel.lastIndex).toBe(3); @@ -75,7 +81,7 @@ describe('The Carousel class', () => { const carousel = new Carousel(div); const spy = vi.spyOn(carousel, 'goTo'); await mount(carousel); - expect(spy).toHaveBeenCalledExactlyOnceWith(carousel.index); + expect(spy).toHaveBeenCalledExactlyOnceWith(carousel.currentIndex); }); it('should go to the current index on resize', async () => { @@ -83,6 +89,6 @@ describe('The Carousel class', () => { const carousel = new Carousel(div); const spy = vi.spyOn(carousel, 'goTo'); carousel.resized(); - expect(spy).toHaveBeenCalledExactlyOnceWith(carousel.index); + expect(spy).toHaveBeenCalledExactlyOnceWith(carousel.currentIndex); }); }); diff --git a/packages/tests/Carousel/CarouselBtn.spec.ts b/packages/tests/Carousel/CarouselBtn.spec.ts index 0fd0eb83..27b18a50 100644 --- a/packages/tests/Carousel/CarouselBtn.spec.ts +++ b/packages/tests/Carousel/CarouselBtn.spec.ts @@ -15,7 +15,7 @@ describe('The CarouselBtn class', () => { const carouselBtn = new CarouselBtn(btn); const spy = vi.spyOn(carousel, method); - spy.mockImplementation(() => {}); + spy.mockImplementation(() => Promise.resolve()); carouselBtn.onClick(); expect(spy).toHaveBeenCalledOnce(); }); @@ -34,7 +34,7 @@ describe('The CarouselBtn class', () => { const carouselBtn = new CarouselBtn(btn); const spy = vi.spyOn(carouselBtn, 'carousel', 'get'); // @ts-expect-error mock is partial - spy.mockImplementation(() => ({ index, lastIndex })); + spy.mockImplementation(() => ({ currentIndex: index, lastIndex })); carouselBtn.onParentCarouselProgress(); expect(btn.disabled).toBe(isDisabled); }); diff --git a/packages/tests/Carousel/CarouselItem.spec.ts b/packages/tests/Carousel/CarouselItem.spec.ts index 6cfc1884..1e4ca7e7 100644 --- a/packages/tests/Carousel/CarouselItem.spec.ts +++ b/packages/tests/Carousel/CarouselItem.spec.ts @@ -28,14 +28,14 @@ describe('The CarouselItem class', () => { const carouselItem = new CarouselItem(div); vi.spyOn(carouselItem, 'index', 'get').mockImplementation(() => 0); // @ts-expect-error partial mock - vi.spyOn(carouselItem, 'carousel', 'get').mockImplementation(() => ({ index: 0 })); + vi.spyOn(carouselItem, 'carousel', 'get').mockImplementation(() => ({ currentIndex: 0 })); carouselItem.onParentCarouselProgress(); await wait(20); expect(div.style.getPropertyValue('--carousel-item-active')).toBe('1'); // @ts-expect-error partial mock - vi.spyOn(carouselItem, 'carousel', 'get').mockImplementation(() => ({ index: 1 })); + vi.spyOn(carouselItem, 'carousel', 'get').mockImplementation(() => ({ currentIndex: 1 })); carouselItem.onParentCarouselProgress(); expect(div.style.getPropertyValue('--carousel-item-active')).toBe('1'); await wait(20); diff --git a/packages/tests/Carousel/CarouselWrapper.spec.ts b/packages/tests/Carousel/CarouselWrapper.spec.ts index 9706bb20..4d534716 100644 --- a/packages/tests/Carousel/CarouselWrapper.spec.ts +++ b/packages/tests/Carousel/CarouselWrapper.spec.ts @@ -49,7 +49,7 @@ describe('The CarouselWrapper class', () => { const div = h('div'); const carouselWrapper = new CarouselWrapper(div); const mock = { - index: 0, + currentIndex: 0, $services: { enable: vi.fn(), }, @@ -79,14 +79,14 @@ describe('The CarouselWrapper class', () => { carouselWrapper.onScroll(); - expect(mock.index).toBe(0); + expect(mock.currentIndex).toBe(0); expect(mock.$services.enable).toHaveBeenCalledExactlyOnceWith('ticked'); vi.spyOn(div, 'scrollLeft', 'get').mockImplementation(() => -100); vi.spyOn(div, 'scrollTop', 'get').mockImplementation(() => -100); carouselWrapper.onScroll(); - expect(mock.index).toBe(1); + expect(mock.currentIndex).toBe(1); vi.spyOn(carouselWrapper, 'isHorizontal', 'get').mockImplementation(() => false); vi.spyOn(carouselWrapper, 'isVertical', 'get').mockImplementation(() => true); @@ -94,14 +94,14 @@ describe('The CarouselWrapper class', () => { vi.spyOn(div, 'scrollTop', 'get').mockImplementation(() => -90); carouselWrapper.onScroll(); - expect(mock.index).toBe(1); + expect(mock.currentIndex).toBe(1); }); it('should scroll to the matching item when the carousel goes to', async () => { const div = h('div'); const carouselWrapper = new CarouselWrapper(div); const mock = { - index: 0, + currentIndex: 0, items: [ { state: { @@ -122,7 +122,7 @@ describe('The CarouselWrapper class', () => { carousel.mockImplementation(() => mock); const spy = vi.spyOn(div, 'scrollTo'); - carouselWrapper.onParentCarouselGoTo(); + carouselWrapper.onParentCarouselIndex(); expect(spy).toHaveBeenCalledExactlyOnceWith({ left: 0, top: 0, @@ -130,9 +130,9 @@ describe('The CarouselWrapper class', () => { }); spy.mockClear(); - mock.index = 1; + mock.currentIndex = 1; - carouselWrapper.onParentCarouselGoTo(); + carouselWrapper.onParentCarouselIndex(); expect(spy).toHaveBeenCalledExactlyOnceWith({ left: -100, top: -100, diff --git a/packages/ui/Carousel/AbstractCarouselChild.ts b/packages/ui/Carousel/AbstractCarouselChild.ts index f6a883bb..d52318a7 100644 --- a/packages/ui/Carousel/AbstractCarouselChild.ts +++ b/packages/ui/Carousel/AbstractCarouselChild.ts @@ -11,7 +11,7 @@ export class AbstractCarouselChild extends Base */ static config: BaseConfig = { name: 'AbstractCarouselChild', - emits: ['parent-carousel-go-to', 'parent-carousel-progress'], + emits: ['parent-carousel-index', 'parent-carousel-progress'], }; /** @@ -41,7 +41,7 @@ export class AbstractCarouselChild extends Base */ handleEvent(event: CustomEvent) { switch (event.type) { - case 'go-to': + case 'index': case 'progress': this.$emit(`parent-carousel-${event.type}`, ...event.detail); break; @@ -60,7 +60,7 @@ export class AbstractCarouselChild extends Base return; } - carousel.$on('go-to', this); + carousel.$on('index', this); carousel.$on('progress', this); } @@ -68,7 +68,7 @@ export class AbstractCarouselChild extends Base * Destroyed hook. */ destroyed() { - this.carousel?.$off?.('go-to', this); + this.carousel?.$off?.('index', this); this.carousel?.$off?.('progress', this); } } diff --git a/packages/ui/Carousel/Carousel.ts b/packages/ui/Carousel/Carousel.ts index 30df8864..c38b1c05 100644 --- a/packages/ui/Carousel/Carousel.ts +++ b/packages/ui/Carousel/Carousel.ts @@ -1,5 +1,6 @@ -import { Base } from '@studiometa/js-toolkit'; -import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import type { BaseConfig } from '@studiometa/js-toolkit'; +import type { IndexableInstructions, IndexableProps } from '../decorators/index.js'; +import { Indexable } from '../Indexable/index.js'; import { CarouselBtn } from './CarouselBtn.js'; import { CarouselDrag } from './CarouselDrag.js'; import { CarouselItem } from './CarouselItem.js'; @@ -23,7 +24,7 @@ export interface CarouselProps { /** * Carousel class. */ -export class Carousel extends Base { +export class Carousel extends Indexable { /** * Config. */ @@ -36,51 +37,12 @@ export class Carousel extends Base extends Base extends Base extends AbstractCarous */ onParentCarouselProgress() { const { action } = this.$options; - const { index, lastIndex } = this.carousel; + const { currentIndex, lastIndex } = this.carousel; const shouldDisable = - (action === 'next' && index === lastIndex) || - (action === 'prev' && index === 0) || - Number(action) === index; + (action === 'next' && currentIndex === lastIndex) || + (action === 'prev' && currentIndex === 0) || + Number(action) === currentIndex; this.$el.disabled = shouldDisable; } diff --git a/packages/ui/Carousel/CarouselItem.ts b/packages/ui/Carousel/CarouselItem.ts index ec755a14..b971c30e 100644 --- a/packages/ui/Carousel/CarouselItem.ts +++ b/packages/ui/Carousel/CarouselItem.ts @@ -60,7 +60,7 @@ export class CarouselItem extends AbstractCarou onParentCarouselProgress() { domScheduler.read(() => { const { index } = this; - const { index: carouselIndex } = this.carousel; + const { currentIndex: carouselIndex } = this.carousel; domScheduler.write(() => { this.$el.style.setProperty( diff --git a/packages/ui/Carousel/CarouselWrapper.ts b/packages/ui/Carousel/CarouselWrapper.ts index cd364507..37e9d7e4 100644 --- a/packages/ui/Carousel/CarouselWrapper.ts +++ b/packages/ui/Carousel/CarouselWrapper.ts @@ -46,15 +46,15 @@ export class CarouselWrapper extends AbstractCa isHorizontal ? $el.scrollLeft : $el.scrollTop, ); - carousel.index = minDiffIndex; + carousel.currentIndex = minDiffIndex; this.carousel.$services.enable('ticked'); } /** * Scroll to the new item on parent carousel go-to event. */ - onParentCarouselGoTo() { - const { state } = this.carousel.items[this.carousel.index]; + onParentCarouselIndex() { + const { state } = this.carousel.items[this.carousel.currentIndex]; if (state) { this.$el.scrollTo({ left: state.left, top: state.top, behavior: 'smooth' }); } diff --git a/packages/ui/Indexable/Indexable.ts b/packages/ui/Indexable/Indexable.ts index d4abb865..b6f6305c 100644 --- a/packages/ui/Indexable/Indexable.ts +++ b/packages/ui/Indexable/Indexable.ts @@ -10,5 +10,6 @@ export class Indexable extends withIndex( */ static config: BaseConfig = { name: 'Indexable', + emits: ['index'] }; } From d2b5ad922be48a6bdcbb5a7399ccd85901d6f6b6 Mon Sep 17 00:00:00 2001 From: Antoine Quatrelivre Date: Thu, 2 Oct 2025 15:00:30 +0200 Subject: [PATCH 14/16] Fix @titouanmathis' PR feedbacks --- packages/ui/Carousel/Carousel.ts | 2 +- packages/ui/decorators/withIndex.ts | 56 ++++++++++++++--------------- 2 files changed, 27 insertions(+), 31 deletions(-) diff --git a/packages/ui/Carousel/Carousel.ts b/packages/ui/Carousel/Carousel.ts index c38b1c05..ea4e5a95 100644 --- a/packages/ui/Carousel/Carousel.ts +++ b/packages/ui/Carousel/Carousel.ts @@ -40,7 +40,7 @@ export class Carousel extends Indexab ...Indexable.config.options, axis: { type: String, default: 'x' }, }, - emits: [...(Indexable.config.emits || []), 'progress'], + emits: ['progress'], }; /** diff --git a/packages/ui/decorators/withIndex.ts b/packages/ui/decorators/withIndex.ts index 85b03a22..7551c235 100644 --- a/packages/ui/decorators/withIndex.ts +++ b/packages/ui/decorators/withIndex.ts @@ -107,15 +107,18 @@ export interface IndexableInterface extends BaseInterface { goPrev(): Promise; } +export interface IndexableConstructor { + MODES: typeof INDEXABLE_MODES; + INSTRUCTIONS: typeof INDEXABLE_INSTRUCTIONS; + new (): IndexableInterface; +} + /** * Extend a class to add index management. */ export function withIndex( BaseClass: typeof Base, -): BaseDecorator & { - MODES: typeof INDEXABLE_MODES; - INSTRUCTIONS: typeof INDEXABLE_INSTRUCTIONS; -} { +): BaseDecorator { /** * Class. */ @@ -135,6 +138,10 @@ export function withIndex( }, }; + static MODES = INDEXABLE_MODES; + + static INSTRUCTIONS = INDEXABLE_INSTRUCTIONS; + __index = 0; get isReverse() { @@ -146,17 +153,11 @@ export function withIndex( } get mode() { - const { mode } = this.$options; - - if (!Object.values(INDEXABLE_MODES).includes(mode)) { - return INDEXABLE_MODES.NORMAL; - } - - return mode; + return Indexable.MODES[this.$options.mode.toUpperCase()] ?? Indexable.MODES.NORMAL; } set mode(value) { - this.$options.mode = Object.values(INDEXABLE_MODES).includes(value) ? value : INDEXABLE_MODES.NORMAL; + this.$options.mode = Indexable.MODES[value.toUpperCase()] ?? Indexable.MODES.NORMAL; } get length() { @@ -178,7 +179,7 @@ export function withIndex( set currentIndex(value) { switch (this.mode) { - case INDEXABLE_MODES.ALTERNATE: + case Indexable.MODES.ALTERNATE: if (Math.floor(value/this.length) % 2 !== 0) { this.isReverse = !this.isReverse; } @@ -186,10 +187,10 @@ export function withIndex( const cycleIndex = Math.abs(value) % cycleLength; this.__index = Math.min(cycleIndex, cycleLength - cycleIndex); break; - case INDEXABLE_MODES.INFINITE: + case Indexable.MODES.INFINITE: this.__index = (value + this.length) % this.length break; - case INDEXABLE_MODES.NORMAL: + case Indexable.MODES.NORMAL: default: this.__index = clamp(value, this.minIndex, this.maxIndex); break; @@ -207,34 +208,34 @@ export function withIndex( get prevIndex() { let rawIndex = this.isReverse ? this.currentIndex + 1 : this.currentIndex - 1; - if (this.mode === INDEXABLE_MODES.ALTERNATE && (rawIndex > this.maxIndex || rawIndex < this.minIndex)) { + if (this.mode === Indexable.MODES.ALTERNATE && (rawIndex > this.maxIndex || rawIndex < this.minIndex)) { this.isReverse = !this.isReverse; rawIndex = this.isReverse ? this.currentIndex + 1 : this.currentIndex - 1; } - return this.mode === INDEXABLE_MODES.NORMAL ? clamp(rawIndex, this.minIndex, this.maxIndex) : (rawIndex + this.length) % this.length; + return this.mode === Indexable.MODES.NORMAL ? clamp(rawIndex, this.minIndex, this.maxIndex) : (rawIndex + this.length) % this.length; } get nextIndex() { let rawIndex = this.isReverse ? this.currentIndex - 1 : this.currentIndex + 1; - if (this.mode === INDEXABLE_MODES.ALTERNATE && (rawIndex > this.maxIndex || rawIndex < this.minIndex)) { + if (this.mode === Indexable.MODES.ALTERNATE && (rawIndex > this.maxIndex || rawIndex < this.minIndex)) { this.isReverse = !this.isReverse; rawIndex = this.isReverse ? this.currentIndex - 1 : this.currentIndex + 1; } - return this.mode === INDEXABLE_MODES.NORMAL ? clamp(rawIndex, this.minIndex, this.maxIndex) : (rawIndex + this.length) % this.length; + return this.mode === Indexable.MODES.NORMAL ? clamp(rawIndex, this.minIndex, this.maxIndex) : (rawIndex + this.length) % this.length; } goTo(indexOrInstruction) { if (isString(indexOrInstruction)) { switch (indexOrInstruction) { - case INDEXABLE_INSTRUCTIONS.NEXT: + case Indexable.INSTRUCTIONS.NEXT: return this.goTo(this.nextIndex); - case INDEXABLE_INSTRUCTIONS.PREVIOUS: + case Indexable.INSTRUCTIONS.PREVIOUS: return this.goTo(this.prevIndex); - case INDEXABLE_INSTRUCTIONS.FIRST: + case Indexable.INSTRUCTIONS.FIRST: return this.goTo(this.firstIndex); - case INDEXABLE_INSTRUCTIONS.LAST: + case Indexable.INSTRUCTIONS.LAST: return this.goTo(this.lastIndex); - case INDEXABLE_INSTRUCTIONS.RANDOM: + case Indexable.INSTRUCTIONS.RANDOM: // @TODO: eventually store previous indexes to avoid duplicates return this.goTo(randomInt(this.minIndex, this.maxIndex)); default: @@ -255,10 +256,5 @@ export function withIndex( } } - // Add constants as static properties to the returned class - const IndexableWithConstants = Indexable as any; - IndexableWithConstants.MODES = INDEXABLE_MODES; - IndexableWithConstants.INSTRUCTIONS = INDEXABLE_INSTRUCTIONS; - - return IndexableWithConstants; + return Indexable; } From d9da3a470794fafd7800d69384bb6bb2657e9bb7 Mon Sep 17 00:00:00 2001 From: Antoine Quatrelivre Date: Thu, 2 Oct 2025 15:34:07 +0200 Subject: [PATCH 15/16] Use strict equality in carousel progress comparison --- packages/ui/Carousel/Carousel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/Carousel/Carousel.ts b/packages/ui/Carousel/Carousel.ts index ea4e5a95..db515171 100644 --- a/packages/ui/Carousel/Carousel.ts +++ b/packages/ui/Carousel/Carousel.ts @@ -114,7 +114,7 @@ export class Carousel extends Indexab } ticked() { - if (this.progress != this.previousProgress) { + if (this.progress !== this.previousProgress) { this.previousProgress = this.progress; this.$emit('progress', this.progress); this.$el.style.setProperty('--carousel-progress', String(this.progress)); From c68468c34205f148fe3f853bc2c828c9752b0698 Mon Sep 17 00:00:00 2001 From: Antoine Quatrelivre Date: Thu, 2 Oct 2025 15:46:43 +0200 Subject: [PATCH 16/16] Ignore type error of the return of withIndex decorator --- packages/ui/decorators/withIndex.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/decorators/withIndex.ts b/packages/ui/decorators/withIndex.ts index 7551c235..fc3bbad1 100644 --- a/packages/ui/decorators/withIndex.ts +++ b/packages/ui/decorators/withIndex.ts @@ -256,5 +256,6 @@ export function withIndex( } } + // @ts-ignore return Indexable; }