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/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..1d2eca60
--- /dev/null
+++ b/packages/docs/components/Carousel/stories/horizontal/app.twig
@@ -0,0 +1,56 @@
+{% 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..f1dd982f
--- /dev/null
+++ b/packages/docs/components/Carousel/stories/vertical/app.twig
@@ -0,0 +1,58 @@
+{% 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';
diff --git a/packages/tests/Carousel/AbstractCarouselChild.spec.ts b/packages/tests/Carousel/AbstractCarouselChild.spec.ts
new file mode 100644
index 00000000..71a7c787
--- /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 ['index', 'progress']) {
+ carousel.$emit(eventName, 0);
+ expect(spy).toHaveBeenCalledExactlyOnceWith(`parent-carousel-${eventName}`, 0);
+ spy.mockClear();
+ }
+
+ await destroy(child);
+ spy.mockClear();
+
+ for (const eventName of ['index', '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..593264c1
--- /dev/null
+++ b/packages/tests/Carousel/Carousel.spec.ts
@@ -0,0 +1,94 @@
+import { describe, it, expect, vi } from 'vitest';
+import { Carousel } from '@studiometa/ui';
+import { h, mount, wait } 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 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);
+ await mount(carousel);
+ const indexFn = vi.fn();
+ const progressFn = vi.fn();
+ carousel.$on('index', indexFn);
+ carousel.$on('progress', progressFn);
+ carousel.goTo(0);
+ expect(indexFn).toHaveBeenCalledOnce();
+ expect(indexFn.mock.lastCall[0].detail).toEqual([0]);
+ await wait();
+ expect(progressFn).toHaveBeenCalledOnce();
+ progressFn.mockClear();
+ carousel.goTo(1);
+ expect(indexFn.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.currentIndex).toBe(0);
+ expect(carousel.prevIndex).toBe(0);
+ expect(carousel.nextIndex).toBe(1);
+ expect(carousel.lastIndex).toBe(3);
+
+ carousel.goTo(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.currentIndex).toBe(2);
+ expect(carousel.prevIndex).toBe(1);
+ expect(carousel.nextIndex).toBe(3);
+ expect(carousel.lastIndex).toBe(3);
+
+ carousel.goPrev();
+
+ expect(carousel.currentIndex).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.currentIndex);
+ });
+
+ 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.currentIndex);
+ });
+});
diff --git a/packages/tests/Carousel/CarouselBtn.spec.ts b/packages/tests/Carousel/CarouselBtn.spec.ts
new file mode 100644
index 00000000..27b18a50
--- /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(() => Promise.resolve());
+ 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(() => ({ currentIndex: 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..1e4ca7e7
--- /dev/null
+++ b/packages/tests/Carousel/CarouselItem.spec.ts
@@ -0,0 +1,61 @@
+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(() => ({ 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(() => ({ currentIndex: 1 }));
+ carouselItem.onParentCarouselProgress();
+ expect(div.style.getPropertyValue('--carousel-item-active')).toBe('1');
+ await wait(20);
+ expect(div.style.getPropertyValue('--carousel-item-active')).toBe('0');
+ });
+
+ it('should reset its state on window resize', async () => {
+ vi.mock('compute-scroll-into-view', () => ({
+ compute: (target: Element) => [{ el: target, top: 0, left: 0 }],
+ }));
+ const item = h('div', { dataComponent: 'CarouselItem' });
+ const wrapper = h('div', { dataComponent: 'CarouselWrapper' }, [item]);
+ const div = h('div', [wrapper]);
+ const carousel = new Carousel(div);
+ await mount(carousel);
+ const carouselItem = getInstanceFromElement(item, CarouselItem);
+ const { state } = carouselItem;
+ expect(carouselItem.state).toBe(state);
+ carouselItem.resized();
+ expect(carouselItem.state).not.toBe(state);
+ expect(carouselItem.state).toEqual(state);
+ });
+});
diff --git a/packages/tests/Carousel/CarouselWrapper.spec.ts b/packages/tests/Carousel/CarouselWrapper.spec.ts
new file mode 100644
index 00000000..4d534716
--- /dev/null
+++ b/packages/tests/Carousel/CarouselWrapper.spec.ts
@@ -0,0 +1,142 @@
+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 = {
+ currentIndex: 0,
+ $services: {
+ enable: 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.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.currentIndex).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.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 = {
+ currentIndex: 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.onParentCarouselIndex();
+ expect(spy).toHaveBeenCalledExactlyOnceWith({
+ left: 0,
+ top: 0,
+ behavior: 'smooth',
+ });
+ spy.mockClear();
+
+ mock.currentIndex = 1;
+
+ carouselWrapper.onParentCarouselIndex();
+ 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/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/__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..40433017 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,11 @@ test('components exports', () => {
"AnchorNavLink",
"AnchorNavTarget",
"AnchorScrollTo",
+ "Carousel",
+ "CarouselBtn",
+ "CarouselDrag",
+ "CarouselItem",
+ "CarouselWrapper",
"CircularMarquee",
"Cursor",
"DataBind",
@@ -34,6 +40,7 @@ test('components exports', () => {
"FrameTarget",
"FrameTriggerLoader",
"Hoverable",
+ "Indexable",
"LargeText",
"LazyInclude",
"Menu",
@@ -64,6 +71,7 @@ test('components exports', () => {
"Transition",
"animationScrollWithEase",
"withDeprecation",
+ "withIndex",
"withTransition",
]
`);
diff --git a/packages/ui/Carousel/AbstractCarouselChild.ts b/packages/ui/Carousel/AbstractCarouselChild.ts
new file mode 100644
index 00000000..d52318a7
--- /dev/null
+++ b/packages/ui/Carousel/AbstractCarouselChild.ts
@@ -0,0 +1,74 @@
+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-index', '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 'index':
+ case 'progress':
+ this.$emit(`parent-carousel-${event.type}`, ...event.detail);
+ break;
+ }
+ }
+
+ /**
+ * Mounted hook.
+ */
+ mounted() {
+ const { carousel } = this;
+
+ if (!carousel) {
+ this.$warn('Could not find a parent slider, not mounting.');
+ this.$destroy();
+ return;
+ }
+
+ carousel.$on('index', this);
+ carousel.$on('progress', this);
+ }
+
+ /**
+ * Destroyed hook.
+ */
+ destroyed() {
+ this.carousel?.$off?.('index', 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..db515171
--- /dev/null
+++ b/packages/ui/Carousel/Carousel.ts
@@ -0,0 +1,125 @@
+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';
+import { CarouselWrapper } from './CarouselWrapper.js';
+
+/**
+ * Props for the Carousel class.
+ */
+export interface CarouselProps {
+ $children: {
+ CarouselBtn: CarouselBtn[];
+ CarouselDrag: CarouselDrag[];
+ CarouselItem: CarouselItem[];
+ CarouselWrapper: CarouselWrapper[];
+ };
+ $options: {
+ axis: 'x' | 'y';
+ };
+}
+
+/**
+ * Carousel class.
+ */
+export class Carousel extends Indexable {
+ /**
+ * Config.
+ */
+ static config: BaseConfig = {
+ name: 'Carousel',
+ components: {
+ CarouselBtn,
+ CarouselDrag,
+ CarouselItem,
+ CarouselWrapper,
+ },
+ options: {
+ ...Indexable.config.options,
+ axis: { type: String, default: 'x' },
+ },
+ emits: ['progress'],
+ };
+
+ /**
+ * 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 length.
+ */
+ get length() {
+ return this.items?.length || 0;
+ }
+
+ /**
+ * Get the carousel's wrapper.
+ */
+ get wrapper() {
+ return this.$children.CarouselWrapper?.[0];
+ }
+
+ /**
+ * Previous progress value.
+ */
+ previousProgress = -1;
+
+ /**
+ * Progress from 0 to 1.
+ */
+ get progress() {
+ return this.wrapper?.progress ?? 0;
+ }
+
+ /**
+ * Mounted hook.
+ */
+ mounted() {
+ this.goTo(this.currentIndex);
+ }
+
+ /**
+ * Resized hook.
+ */
+ resized() {
+ this.goTo(this.currentIndex);
+ }
+
+ /**
+ * Go to the given item.
+ */
+ goTo(indexOrInstruction: number | IndexableInstructions) {
+ this.$log('goTo', indexOrInstruction);
+ this.$services.enable('ticked');
+ return super.goTo(indexOrInstruction);
+ }
+
+ ticked() {
+ if (this.progress !== this.previousProgress) {
+ this.previousProgress = this.progress;
+ this.$emit('progress', this.progress);
+ this.$el.style.setProperty('--carousel-progress', String(this.progress));
+ } else {
+ this.$services.disable('ticked');
+ }
+ }
+}
diff --git a/packages/ui/Carousel/CarouselBtn.ts b/packages/ui/Carousel/CarouselBtn.ts
new file mode 100644
index 00000000..3216acee
--- /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 { currentIndex, lastIndex } = this.carousel;
+ const shouldDisable =
+ (action === 'next' && currentIndex === lastIndex) ||
+ (action === 'prev' && currentIndex === 0) ||
+ Number(action) === currentIndex;
+
+ 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..b971c30e
--- /dev/null
+++ b/packages/ui/Carousel/CarouselItem.ts
@@ -0,0 +1,73 @@
+import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit';
+import type { ScrollAction } from 'compute-scroll-into-view';
+import { domScheduler } from '@studiometa/js-toolkit/utils';
+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);
+ }
+
+ __state: ScrollAction;
+ __shouldEvaluateState = true;
+
+ /**
+ * The item's active state descriptor.
+ */
+ get state(): ScrollAction {
+ if (this.__shouldEvaluateState) {
+ const [state] = compute(this.$el, {
+ block: 'center',
+ inline: 'center',
+ boundary: this.carousel.wrapper.$el,
+ });
+ this.__state = state;
+ this.__shouldEvaluateState = false;
+ }
+
+ return this.__state;
+ }
+
+ resized() {
+ this.__shouldEvaluateState = true;
+ }
+
+ /**
+ * Update the item's state on parent carousel progress.
+ * @todo a11y
+ */
+ onParentCarouselProgress() {
+ domScheduler.read(() => {
+ const { index } = this;
+ const { currentIndex: 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
new file mode 100644
index 00000000..37e9d7e4
--- /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.currentIndex = minDiffIndex;
+ this.carousel.$services.enable('ticked');
+ }
+
+ /**
+ * Scroll to the new item on parent carousel go-to event.
+ */
+ 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/Carousel/index.ts b/packages/ui/Carousel/index.ts
new file mode 100644
index 00000000..d46a5b33
--- /dev/null
+++ b/packages/ui/Carousel/index.ts
@@ -0,0 +1,6 @@
+export * from './Carousel.js';
+export * from './CarouselBtn.js';
+export * from './CarouselDrag.js';
+export * from './CarouselItem.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/Indexable/Indexable.ts b/packages/ui/Indexable/Indexable.ts
new file mode 100644
index 00000000..b6f6305c
--- /dev/null
+++ b/packages/ui/Indexable/Indexable.ts
@@ -0,0 +1,15 @@
+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',
+ emits: ['index']
+ };
+}
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..fc3bbad1
--- /dev/null
+++ b/packages/ui/decorators/withIndex.ts
@@ -0,0 +1,261 @@
+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;
+}
+
+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 {
+ /**
+ * Class.
+ */
+ class Indexable extends BaseClass {
+ /**
+ * Config.
+ */
+ static config: BaseConfig = {
+ ...BaseClass.config,
+ emits: ['index'],
+ options: {
+ mode: {
+ type: String,
+ default: INDEXABLE_MODES.NORMAL,
+ },
+ reverse: Boolean,
+ },
+ };
+
+ static MODES = INDEXABLE_MODES;
+
+ static INSTRUCTIONS = INDEXABLE_INSTRUCTIONS;
+
+ __index = 0;
+
+ get isReverse() {
+ return this.$options.reverse === true;
+ }
+
+ set isReverse(value) {
+ this.$options.reverse = !!value;
+ }
+
+ get mode() {
+ return Indexable.MODES[this.$options.mode.toUpperCase()] ?? Indexable.MODES.NORMAL;
+ }
+
+ set mode(value) {
+ this.$options.mode = Indexable.MODES[value.toUpperCase()] ?? 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);
+ }
+ }
+
+ // @ts-ignore
+ return Indexable;
+}
diff --git a/packages/ui/index.ts b/packages/ui/index.ts
index 48ca4f4d..0f972c3b 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';
@@ -10,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';
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"
},