diff --git a/package-lock.json b/package-lock.json index fc48efa4..cabc8a84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6033,6 +6033,12 @@ "node": ">= 10" } }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz", + "integrity": "sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg==", + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -19410,6 +19416,7 @@ "license": "MIT", "dependencies": { "@studiometa/js-toolkit": "^3.0.0-alpha.10", + "compute-scroll-into-view": "3.1.0", "deepmerge": "^4.3.1" } } diff --git a/packages/tests/index.spec.ts b/packages/tests/index.spec.ts index 6c48b176..f5aa82b6 100644 --- a/packages/tests/index.spec.ts +++ b/packages/tests/index.spec.ts @@ -14,6 +14,13 @@ test('components exports', () => { "AnchorNavLink", "AnchorNavTarget", "AnchorScrollTo", + "Carousel", + "CarouselBtn", + "CarouselDots", + "CarouselDrag", + "CarouselItem", + "CarouselProgress", + "CarouselWrapper", "CircularMarquee", "Cursor", "DataBind", diff --git a/packages/ui/molecules/Carousel/AbstractCarouselChild.ts b/packages/ui/molecules/Carousel/AbstractCarouselChild.ts new file mode 100644 index 00000000..f8259ae3 --- /dev/null +++ b/packages/ui/molecules/Carousel/AbstractCarouselChild.ts @@ -0,0 +1,56 @@ +import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import { Base, getClosestParent } from '@studiometa/js-toolkit'; +import { Carousel } from './Carousel.js'; + +/** + * AbstractCarouselChild class. + */ +export class AbstractCarouselChild extends Base { + /** + * Config. + */ + static config: BaseConfig = { + name: 'AbstractCarouselChild', + emits: ['parent-carousel-go-to', 'parent-carousel-progress'], + }; + + /** + * Get the parent carousel instance. + */ + get slider() { + // @todo data-option-carousel for better grouping? + return getClosestParent(this, Carousel); + } + + /** + * Disptach events from the parent carousel on the child components. + */ + handleEvent(event: CustomEvent) { + switch (event.type) { + case 'go-to': + case 'progress': + this.$emit(`parent-carousel-${event.type}`, ...event.detail); + break; + } + } + + /** + * Mounted hook. + */ + mounted() { + if (!this.slider) { + throw new Error('Could not find a parent slider.'); + } + + this.slider.$on('go-to', this); + this.slider.$on('progress', this); + } + + /** + * Destroyed hook. + */ + destroyed() { + this.slider?.$off('go-to', this); + this.slider?.$off('progress', this); + } +} diff --git a/packages/ui/molecules/Carousel/Carousel.ts b/packages/ui/molecules/Carousel/Carousel.ts new file mode 100644 index 00000000..be0a1030 --- /dev/null +++ b/packages/ui/molecules/Carousel/Carousel.ts @@ -0,0 +1,116 @@ +import { Base } from '@studiometa/js-toolkit'; +import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import { CarouselItem } from './CarouselItem.js'; +import { CarouselWrapper } from './CarouselWrapper.js'; + +/** + * Props for the Carousel class. + */ +export interface CarouselProps { + $children: { + CarouselItem: CarouselItem[]; + CarouselWrapper: CarouselWrapper[]; + }; +} + +/** + * Carousel class. + */ +export class Carousel extends Base { + /** + * Config. + */ + static config: BaseConfig = { + name: 'Slider', + components: { + CarouselItem, + CarouselWrapper, + }, + emits: ['go-to', 'progress'], + }; + + /** + * Carousel index. + */ + index = 0; + + /** + * Get the carousel's wrapper. + */ + get wrapper() { + return this.$children.CarouselWrapper[0]; + } + + /** + * Get the carousel's items. + */ + get items() { + return this.$children.CarouselItem; + } + + /** + * Previous index. + */ + get prevIndex() { + return Math.max(this.index - 1, 0); + } + + /** + * Next index. + */ + get nextIndex() { + return Math.min(this.index + 1, this.lastIndex); + } + + /** + * Last index. + */ + get lastIndex() { + return this.items.length - 1; + } + + /** + * Progress from 0 to 1. + */ + get progress() { + return this.wrapper.progress; + } + + /** + * Mounted hook. + */ + mounted() { + this.goTo(this.index); + } + + /** + * Resized hook. + */ + resized() { + this.goTo(this.index); + } + + /** + * Go to the previous item. + */ + goPrev() { + this.goTo(this.prevIndex); + } + + /** + * Go to the next item. + */ + goNext() { + this.goTo(this.nextIndex); + } + + /** + * Go to the given item. + */ + goTo(index) { + console.log('goTo', index); + this.index = index; + this.$emit('go-to', index); + this.$emit('progress', this.progress); + } +} diff --git a/packages/ui/molecules/Carousel/CarouselBtn.ts b/packages/ui/molecules/Carousel/CarouselBtn.ts new file mode 100644 index 00000000..b54c5549 --- /dev/null +++ b/packages/ui/molecules/Carousel/CarouselBtn.ts @@ -0,0 +1,53 @@ +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: { + direction: 'next' | 'prev'; + }; +} + +/** + * CarouselBtn class. + */ +export class CarouselBtn extends AbstractCarouselChild< + T & CarouselBtnProps +> { + /** + * Config. + */ + static config: BaseConfig = { + name: 'CarouselBtn', + options: { direction: String }, + }; + + /** + * Go to the next or previous item on click. + */ + onClick() { + switch (this.$options.direction) { + case 'next': + this.slider.goNext(); + break; + case 'prev': + this.slider.goPrev(); + break; + } + } + + /** + * Update button state on parent carousel progress. + */ + onParentCarouselProgress() { + const { direction } = this.$options; + const { index, lastIndex } = this.slider; + const shouldDisable = + (direction === 'next' && index === lastIndex) || (direction === 'prev' && index === 0); + + this.$el.disabled = shouldDisable; + } +} diff --git a/packages/ui/molecules/Carousel/CarouselDots.ts b/packages/ui/molecules/Carousel/CarouselDots.ts new file mode 100644 index 00000000..2a4af467 --- /dev/null +++ b/packages/ui/molecules/Carousel/CarouselDots.ts @@ -0,0 +1,46 @@ +import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import { AbstractCarouselChild } from './AbstractCarouselChild.js'; + +/** + * Props for the CarouselDots class. + */ +export interface CarouselDotsProps extends BaseProps { + $refs: { + dots: HTMLButtonElement[]; + }; +} + +/** + * CarouselDots class. + */ +export class CarouselDots extends AbstractCarouselChild< + T & CarouselDotsProps +> { + /** + * Config. + * @type {BaseConfig} + */ + static config: BaseConfig = { + name: 'CarouselDots', + refs: ['dots[]'], + }; + + /** + * Go to matching index on dot click. + */ + onDotsClick({ index }) { + this.slider.goTo(index); + } + + /** + * Update dots on parent slider progress. + */ + onParentCarouselProgress() { + const { index } = this.slider; + + this.$refs.dots.forEach((dot, i) => { + dot.disabled = index === i; + dot.classList.toggle('ring-opacity-25', index !== i); + }); + } +} diff --git a/packages/ui/molecules/Carousel/CarouselDrag.ts b/packages/ui/molecules/Carousel/CarouselDrag.ts new file mode 100644 index 00000000..f7f81d3e --- /dev/null +++ b/packages/ui/molecules/Carousel/CarouselDrag.ts @@ -0,0 +1,84 @@ +import type { BaseConfig, BaseProps, DragServiceProps } from '@studiometa/js-toolkit'; +import { withDrag } from '@studiometa/js-toolkit'; +import { inertiaFinalValue, tween, lerp } 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 extends withDrag(AbstractCarouselChild)< + T & CarouselDragProps +> { + /** + * Config. + */ + static config: BaseConfig = { + name: 'CarouselDrag', + }; + + /** + * Tween instance for inertia on drop. + */ + tween: ReturnType; + + /** + * Dragged hook. + */ + dragged(props: DragServiceProps) { + const wrapper = this.$el; + + // do nothing if scroll-snap is enabled as it is better than this drag implementation + if (getComputedStyle(wrapper).scrollSnapType !== 'none') { + return; + } + + // do noting on inertia and stop + if (props.mode === 'inertia' || props.mode === 'stop') { + return; + } + + // do nothing while the distance is 0 + if (props.distance.x === 0) { + return; + } + + if (this.tween) { + this.tween.pause(); + } + + if (props.mode === 'drag') { + const left = wrapper.scrollLeft - props.delta.x; + wrapper.scrollTo({ left, behavior: 'instant' }); + } + + if (props.mode === 'drop') { + const start = wrapper.scrollLeft; + const finalValue = inertiaFinalValue(wrapper.scrollLeft, props.delta.x * -2.5); + const index = getClosestIndex( + this.slider.items.map((item) => item.state.left), + finalValue, + ); + const target = this.slider.items[index].state.left; + + this.tween = tween( + (progress) => { + const left = lerp(start, target, progress); + this.slider.wrapper.scrollTo({ left, behavior: 'instant' }); + }, + { + onFinish: () => { + this.slider.goTo(index); + }, + smooth: 0.25, + }, + ); + this.tween.start(); + } + } +} diff --git a/packages/ui/molecules/Carousel/CarouselItem.ts b/packages/ui/molecules/Carousel/CarouselItem.ts new file mode 100644 index 00000000..0e02a275 --- /dev/null +++ b/packages/ui/molecules/Carousel/CarouselItem.ts @@ -0,0 +1,57 @@ +import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import type { ScrollAction } from 'compute-scroll-into-view'; +import { compute } from 'compute-scroll-into-view'; +import { AbstractCarouselChild } from './AbstractCarouselChild.js'; + +/** + * Props for the CarouselItem class. + */ +export interface CarouselItemProps extends BaseProps {} + +/** + * CarouselItem class. + */ +export class CarouselItem extends AbstractCarouselChild< + T & CarouselItemProps +> { + /** + * Config. + */ + static config: BaseConfig = { + name: 'CarouselItem', + }; + + /** + * The item's index in the carousel. + */ + get index() { + return this.slider.$children.CarouselItem.indexOf(this); + } + + /** + * The item's active state descriptor. + */ + get state(): ScrollAction { + const [state] = compute(this.$el, { + block: 'nearest', + inline: 'center', + boundary: this.slider.wrapper.$el, + }); + return state; + } + + /** + * Update the item's state on parent carousel progress. + */ + onParentCarouselProgress() { + const isActive = this.index === this.slider.index; + this.$el.classList.toggle('ring', isActive); + } + + /** + * Go to the item's index on click. + */ + onClick() { + this.slider.goTo(this.index); + } +} diff --git a/packages/ui/molecules/Carousel/CarouselProgress.ts b/packages/ui/molecules/Carousel/CarouselProgress.ts new file mode 100644 index 00000000..cfb7795b --- /dev/null +++ b/packages/ui/molecules/Carousel/CarouselProgress.ts @@ -0,0 +1,33 @@ +import type { BaseConfig, BaseProps } from '@studiometa/js-toolkit'; +import { AbstractCarouselChild } from './AbstractCarouselChild.js'; + +/** + * Props for the CarouselProgress class. + */ +export interface CarouselProgressProps extends BaseProps { + $refs: { + track: HTMLElement; + }; +} + +/** + * CarouselProgress class. + */ +export class CarouselProgress extends AbstractCarouselChild< + T & CarouselProgressProps +> { + /** + * Config. + */ + static config: BaseConfig = { + name: 'CarouselProgress', + refs: ['track'], + }; + + /** + * Update track style on parent carousel progress. + */ + onParentCarouselProgress() { + this.$refs.track.style.setProperty('--tw-translate-x', (this.slider.progress - 1) * 100 + '%'); + } +} diff --git a/packages/ui/molecules/Carousel/CarouselWrapper.ts b/packages/ui/molecules/Carousel/CarouselWrapper.ts new file mode 100644 index 00000000..25598859 --- /dev/null +++ b/packages/ui/molecules/Carousel/CarouselWrapper.ts @@ -0,0 +1,66 @@ +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() { + const { scrollLeft, scrollWidth, offsetWidth } = this.$el; + return scrollLeft / (scrollWidth - offsetWidth); + } + + /** + * Set the carousel index based on the wrapper scroll position. + */ + setIndexFromScrollPosition() { + const scroll = Math.round(this.$el.scrollLeft); + const minDiffIndex = getClosestIndex( + this.slider.items.map((item) => item.state.left), + scroll, + ); + this.slider.index = minDiffIndex; + } + + /** + * Scroll to the given position. + */ + scrollTo(options: ScrollToOptions) { + this.$el.scrollTo(options); + } + + /** + * Update index and emit progress on wrapper scroll. + */ + onScroll() { + this.setIndexFromScrollPosition(); + this.slider.$emit('progress', this.progress); + } + + + /** + * Scroll to the new item on parent carousel go-to event. + */ + onParentCarouselGoTo() { + const { state } = this.slider.items[this.slider.index]; + this.scrollTo({ left: state.left, behavior: 'smooth' }); + } +} diff --git a/packages/ui/molecules/Carousel/index.ts b/packages/ui/molecules/Carousel/index.ts new file mode 100644 index 00000000..e812e9b8 --- /dev/null +++ b/packages/ui/molecules/Carousel/index.ts @@ -0,0 +1,7 @@ +export * from './Carousel.js'; +export * from './CarouselBtn.js'; +export * from './CarouselDots.js'; +export * from './CarouselDrag.js'; +export * from './CarouselItem.js'; +export * from './CarouselProgress.js'; +export * from './CarouselWrapper.js'; diff --git a/packages/ui/molecules/Carousel/utils.ts b/packages/ui/molecules/Carousel/utils.ts new file mode 100644 index 00000000..aa57acf8 --- /dev/null +++ b/packages/ui/molecules/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/molecules/index.ts b/packages/ui/molecules/index.ts index 87709ed7..831a636e 100644 --- a/packages/ui/molecules/index.ts +++ b/packages/ui/molecules/index.ts @@ -7,3 +7,4 @@ export * from './TableOfContent/index.js'; export * from './Tabs/index.js'; export * from './Modal/index.js'; export * from './Panel/index.js'; +export * from './Carousel/index.js'; diff --git a/packages/ui/package.json b/packages/ui/package.json index b23b1f52..7aa0fae2 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -30,6 +30,7 @@ "homepage": "https://github.com/studiometa/ui#readme", "dependencies": { "@studiometa/js-toolkit": "^3.0.0-alpha.10", + "compute-scroll-into-view": "3.1.0", "deepmerge": "^4.3.1" } }