Skip to content
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.

23 changes: 23 additions & 0 deletions packages/docs/components/Carousel/examples.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
title: Carousel examples
---

# Examples

## Horizontal

<PreviewPlayground
:html="() => import('./stories/horizontal/app.twig')"
:html-editor="false"
:script="() => import('./stories/horizontal/app.js?raw')"
:script-editor="false"
/>

## Vertical

<PreviewPlayground
:html="() => import('./stories/vertical/app.twig')"
:html-editor="false"
:script="() => import('./stories/vertical/app.js?raw')"
:script-editor="false"
/>
45 changes: 45 additions & 0 deletions packages/docs/components/Carousel/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
badges: [JS]
---

# Carousel <Badges :texts="$frontmatter.badges" />

## 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]
<div data-component="Carousel">
<div data-component="CarouselWrapper CarouselDrag" class="whitespace-nowrap">
{% for item in 1..4 %}
<div data-component="CarouselItem" class="inline-block">
#{{ item }}
</div>
{% endfor %}
</div>
</div>
```

:::
13 changes: 13 additions & 0 deletions packages/docs/components/Carousel/stories/horizontal/app.js
Original file line number Diff line number Diff line change
@@ -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);
56 changes: 56 additions & 0 deletions packages/docs/components/Carousel/stories/horizontal/app.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{% set colors = ['red', 'green', 'blue', 'purple'] %}
{% set count = 5 %}

<div data-component="Carousel" class="grid gap-10 p-10" data-option-axis="x">
<div data-component="CarouselWrapper CarouselDrag"
class="flex items-center gap-8 w-full overflow-x-auto snap-x snap-mandatory"
style="scrollbar-width: none;">
{% for i in 1..count %}
{% set color = colors[loop.index0 % (colors|length)] %}
<div data-component="CarouselItem"
class="
snap-center shrink-0 flex items-center justify-center
w-1/3 h-48 bg-{{
color
}}-400 ring-inset ring-{{
color
}}-600
ring
text-white font-bold rounded-xl
"
style="--tw-ring-opacity: var(--carousel-item-active, 0)">
NΒ°{{ i }}
</div>
{% endfor %}
</div>
<div class="relative w-full h-2 rounded-full bg-gray-200 overflow-hidden">
<div style="--tw-translate-x: calc((var(--carousel-progress, 0) - 1) * 100%)"
class="absolute inset-0 -translate-x-full bg-black rounded-full"></div>
</div>
<nav class="flex items-center justify-center gap-2">
{% include '@ui/Button/StyledButton.twig' with {
label: '← Prev',
attr: {
data_component: 'CarouselBtn',
data_option_action: 'prev'
}
} %}
{% for i in 1..count %}
{% include '@ui/Button/StyledButton.twig' with {
label: i,
theme: 'secondary',
attr: {
data_component: 'CarouselBtn',
data_option_action: loop.index0
}
} %}
{% endfor %}
{% include '@ui/Button/StyledButton.twig' with {
label: 'Next β†’',
attr: {
data_component: 'CarouselBtn',
data_option_action: 'next'
}
} %}
</nav>
</div>
13 changes: 13 additions & 0 deletions packages/docs/components/Carousel/stories/vertical/app.js
Original file line number Diff line number Diff line change
@@ -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);
58 changes: 58 additions & 0 deletions packages/docs/components/Carousel/stories/vertical/app.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{% set colors = ['red', 'green', 'blue', 'purple'] %}
{% set count = 5 %}

<div data-component="Carousel"
class="flex items-center justify-start gap-10 p-10"
data-option-axis="y">
<div data-component="CarouselWrapper CarouselDrag"
class="grid gap-4 w-[max-content] h-52 overflow-y-auto snap-y snap-mandatory"
style="scrollbar-width: none;">
{% for i in 1..count %}
{% set color = colors[loop.index0 % (colors|length)] %}
<div data-component="CarouselItem"
class="
snap-center shrink-0 flex items-center justify-center self-center justify-self-center
w-24 h-24 bg-{{
color
}}-400 ring-inset ring-{{
color
}}-600
ring
text-white font-bold rounded-xl
"
style="--tw-ring-opacity: var(--carousel-item-active, 0)">
NΒ°{{ i }}
</div>
{% endfor %}
</div>
<div class="relative h-52 w-2 rounded-full bg-gray-200 overflow-hidden">
<div style="--tw-translate-y: calc((var(--carousel-progress, 0) - 1) * 100%)"
class="absolute inset-0 -translate-y-full bg-black rounded-full"></div>
</div>
<nav class="grid items-center justify-center gap-2">
{% include '@ui/Button/StyledButton.twig' with {
label: '↑',
attr: {
data_component: 'CarouselBtn',
data_option_action: 'prev'
}
} %}
{% for i in 1..count %}
{% include '@ui/Button/StyledButton.twig' with {
label: i,
theme: 'secondary',
attr: {
data_component: 'CarouselBtn',
data_option_action: loop.index0
}
} %}
{% endfor %}
{% include '@ui/Button/StyledButton.twig' with {
label: '↓',
attr: {
data_component: 'CarouselBtn',
data_option_action: 'next'
}
} %}
</nav>
</div>
1 change: 1 addition & 0 deletions packages/playground/meta.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' } %}
Expand Down
1 change: 1 addition & 0 deletions packages/playground/static/compute-scroll-into-view.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from 'compute-scroll-into-view';
47 changes: 47 additions & 0 deletions packages/tests/Carousel/AbstractCarouselChild.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
94 changes: 94 additions & 0 deletions packages/tests/Carousel/Carousel.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading