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" },