Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] Add a Carousel component #320

Open
wants to merge 2 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions packages/tests/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ test('components exports', () => {
"AnchorNavLink",
"AnchorNavTarget",
"AnchorScrollTo",
"Carousel",
"CarouselBtn",
"CarouselDots",
"CarouselDrag",
"CarouselItem",
"CarouselProgress",
"CarouselWrapper",
"CircularMarquee",
"Cursor",
"DataBind",
Expand Down
56 changes: 56 additions & 0 deletions packages/ui/molecules/Carousel/AbstractCarouselChild.ts
Original file line number Diff line number Diff line change
@@ -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<T extends BaseProps = BaseProps> extends Base<T> {
/**
* 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);
}

Check warning on line 23 in packages/ui/molecules/Carousel/AbstractCarouselChild.ts

View check run for this annotation

Codecov / codecov/patch

packages/ui/molecules/Carousel/AbstractCarouselChild.ts#L22-L23

Added lines #L22 - L23 were not covered by tests

/**
* 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;
}
}

Check warning on line 35 in packages/ui/molecules/Carousel/AbstractCarouselChild.ts

View check run for this annotation

Codecov / codecov/patch

packages/ui/molecules/Carousel/AbstractCarouselChild.ts#L29-L35

Added lines #L29 - L35 were not covered by tests

/**
* Mounted hook.
*/
mounted() {
if (!this.slider) {
throw new Error('Could not find a parent slider.');
}

Check warning on line 43 in packages/ui/molecules/Carousel/AbstractCarouselChild.ts

View check run for this annotation

Codecov / codecov/patch

packages/ui/molecules/Carousel/AbstractCarouselChild.ts#L41-L43

Added lines #L41 - L43 were not covered by tests

this.slider.$on('go-to', this);
this.slider.$on('progress', this);
}

Check warning on line 47 in packages/ui/molecules/Carousel/AbstractCarouselChild.ts

View check run for this annotation

Codecov / codecov/patch

packages/ui/molecules/Carousel/AbstractCarouselChild.ts#L45-L47

Added lines #L45 - L47 were not covered by tests

/**
* Destroyed hook.
*/
destroyed() {
this.slider?.$off('go-to', this);
this.slider?.$off('progress', this);
}

Check warning on line 55 in packages/ui/molecules/Carousel/AbstractCarouselChild.ts

View check run for this annotation

Codecov / codecov/patch

packages/ui/molecules/Carousel/AbstractCarouselChild.ts#L53-L55

Added lines #L53 - L55 were not covered by tests
}
116 changes: 116 additions & 0 deletions packages/ui/molecules/Carousel/Carousel.ts
Original file line number Diff line number Diff line change
@@ -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<T extends BaseProps = BaseProps> extends Base<T & CarouselProps> {
/**
* 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);
}
}
53 changes: 53 additions & 0 deletions packages/ui/molecules/Carousel/CarouselBtn.ts
Original file line number Diff line number Diff line change
@@ -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<T extends BaseProps = BaseProps> 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;
}
}

Check warning on line 40 in packages/ui/molecules/Carousel/CarouselBtn.ts

View check run for this annotation

Codecov / codecov/patch

packages/ui/molecules/Carousel/CarouselBtn.ts#L32-L40

Added lines #L32 - L40 were not covered by tests

/**
* 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);

Check warning on line 49 in packages/ui/molecules/Carousel/CarouselBtn.ts

View check run for this annotation

Codecov / codecov/patch

packages/ui/molecules/Carousel/CarouselBtn.ts#L46-L49

Added lines #L46 - L49 were not covered by tests

this.$el.disabled = shouldDisable;
}

Check warning on line 52 in packages/ui/molecules/Carousel/CarouselBtn.ts

View check run for this annotation

Codecov / codecov/patch

packages/ui/molecules/Carousel/CarouselBtn.ts#L51-L52

Added lines #L51 - L52 were not covered by tests
}
46 changes: 46 additions & 0 deletions packages/ui/molecules/Carousel/CarouselDots.ts
Original file line number Diff line number Diff line change
@@ -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<T extends BaseProps = BaseProps> 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);
}

Check warning on line 33 in packages/ui/molecules/Carousel/CarouselDots.ts

View check run for this annotation

Codecov / codecov/patch

packages/ui/molecules/Carousel/CarouselDots.ts#L32-L33

Added lines #L32 - L33 were not covered by tests

/**
* Update dots on parent slider progress.
*/
onParentCarouselProgress() {
const { index } = this.slider;

Check warning on line 39 in packages/ui/molecules/Carousel/CarouselDots.ts

View check run for this annotation

Codecov / codecov/patch

packages/ui/molecules/Carousel/CarouselDots.ts#L39

Added line #L39 was not covered by tests

this.$refs.dots.forEach((dot, i) => {
dot.disabled = index === i;
dot.classList.toggle('ring-opacity-25', index !== i);
});
}

Check warning on line 45 in packages/ui/molecules/Carousel/CarouselDots.ts

View check run for this annotation

Codecov / codecov/patch

packages/ui/molecules/Carousel/CarouselDots.ts#L41-L45

Added lines #L41 - L45 were not covered by tests
}
84 changes: 84 additions & 0 deletions packages/ui/molecules/Carousel/CarouselDrag.ts
Original file line number Diff line number Diff line change
@@ -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<T> extends withDrag<AbstractCarouselChild>(AbstractCarouselChild)<
T & CarouselDragProps
> {
/**
* Config.
*/
static config: BaseConfig = {
name: 'CarouselDrag',
};

/**
* Tween instance for inertia on drop.
*/
tween: ReturnType<typeof tween>;

/**
* 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();
}
}
}
Loading
Loading