kopia lustrzana https://github.com/shoelace-style/shoelace
feat: add carousel component
feat: add nav indicators wip wip wip fix: minor fixes fix: minor fixes fix: some refactor chore: update docs chore: update docs fix: remove slide component feat: create sl-carousel-item feat: code refactoring and improvements chore: update docs with more examples chore: fix docs feat: add autoplay feat: implement accessibility fix: change icons for rtl chore: minor change feat: improve accessibility fix: minor regression fix: minor regression chore: fix docs fix: improve accessibility and minor fixes fix: remove heading and refactor component chore: add custom style exmaple fix: address review commnets * Removed header from carousel * Added `ArrowUp` and `ArrowDown` in keyboard navigation * Added `--scroll-hint-margin` css property * Added an example with customized carousel layout * Fixed thumbnails navigation in demo * Renamed show-controls to show-arrows and updated the corresponding parts/css accordingly * Changed `activeSlideElement` getter to a private method * Changed pagination colors * Added `--slide-width` and `--slide-height` css properties chore: update docs fix: integrate latest repo changes fix: add aspect ratio and rebase chore: remove ignore path feat: multiple slides per page feat: multiple slide per page fix: various improvements chore: minor changes chore: minor changes chore: add bit of documentation chore: improve documentation fix: add unit tests and fix minor issues chore: update documentation and unit tests chore: update testspull/851/head
rodzic
e632b51eb8
commit
c6a6a77bbd
|
@ -3,6 +3,5 @@
|
||||||
docs/dist
|
docs/dist
|
||||||
docs/search.json
|
docs/search.json
|
||||||
dist
|
dist
|
||||||
examples
|
|
||||||
node_modules
|
node_modules
|
||||||
src/react
|
src/react
|
||||||
|
|
|
@ -31,6 +31,8 @@
|
||||||
- [Button](/components/button)
|
- [Button](/components/button)
|
||||||
- [Button Group](/components/button-group)
|
- [Button Group](/components/button-group)
|
||||||
- [Card](/components/card)
|
- [Card](/components/card)
|
||||||
|
- [Carousel](/components/carousel)
|
||||||
|
- [Carousel Item](/components/carousel-item)
|
||||||
- [Checkbox](/components/checkbox)
|
- [Checkbox](/components/checkbox)
|
||||||
- [Color Picker](/components/color-picker)
|
- [Color Picker](/components/color-picker)
|
||||||
- [Details](/components/details)
|
- [Details](/components/details)
|
||||||
|
|
Plik binarny nie jest wyświetlany.
Po Szerokość: | Wysokość: | Rozmiar: 69 KiB |
Plik binarny nie jest wyświetlany.
Po Szerokość: | Wysokość: | Rozmiar: 100 KiB |
Plik binarny nie jest wyświetlany.
Po Szerokość: | Wysokość: | Rozmiar: 91 KiB |
Plik binarny nie jest wyświetlany.
Po Szerokość: | Wysokość: | Rozmiar: 175 KiB |
Plik binarny nie jest wyświetlany.
Po Szerokość: | Wysokość: | Rozmiar: 53 KiB |
|
@ -0,0 +1,29 @@
|
||||||
|
# Carousel Item
|
||||||
|
|
||||||
|
[component-header:sl-carousel-item]
|
||||||
|
|
||||||
|
```html preview
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash"
|
||||||
|
src="/assets/examples/carousel/leonard-cotte-c1Jp-fo53U8-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsx react
|
||||||
|
import { SlCarouselItem } from '@shoelace-style/shoelace/dist/react';
|
||||||
|
|
||||||
|
const App = () => (
|
||||||
|
<SlCarouselItem>
|
||||||
|
<img
|
||||||
|
alt="The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash"
|
||||||
|
src="/assets/examples/carousel/leonard-cotte-c1Jp-fo53U8-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</SlCarouselItem>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
?> Additional demonstrations can be found in the [carousel examples](/components/carousel).
|
||||||
|
|
||||||
|
[component-metadata:sl-carousel-item]
|
|
@ -0,0 +1,590 @@
|
||||||
|
# Carousel
|
||||||
|
|
||||||
|
[component-header:sl-carousel]
|
||||||
|
|
||||||
|
Carousels consist of optional navigation arrows to go backwards and forwards, as well as optional pagination indicators.
|
||||||
|
|
||||||
|
```html preview
|
||||||
|
<sl-carousel loop pagination navigation autoplay mouse-dragging>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="The sun shines on the mountains and trees - Photo by Adam Kool on Unsplash"
|
||||||
|
src="/assets/examples/carousel/adam-kool-ndN00KmbJ1c-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="A waterfall in the middle of a forest - Photo by Thomas Kelly on Unsplash"
|
||||||
|
src="/assets/examples/carousel/thomas-kelley-JoH60FhTp50-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash"
|
||||||
|
src="/assets/examples/carousel/leonard-cotte-c1Jp-fo53U8-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="A field of grass with the sun setting in the background - Photo by Sapan Patel on Unsplash"
|
||||||
|
src="/assets/examples/carousel/sapan-patel-i9Q9bc-WgfE-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="A scenic view of a mountain with clouds rolling in - Photo by V2osk on Unsplash"
|
||||||
|
src="/assets/examples/carousel/v2osk-1Z2niiBPg5A-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
<sl-divider></sl-divider>
|
||||||
|
<div class="carousel-options">
|
||||||
|
<div class="carousel-options">
|
||||||
|
<sl-switch checked name="loop"> Loop </sl-switch>
|
||||||
|
<sl-switch checked name="navigation"> Show navigation </sl-switch>
|
||||||
|
<sl-switch checked name="pagination"> Show pagination </sl-switch>
|
||||||
|
<sl-switch checked name="autoplay"> Autoplay (3s) </sl-switch>
|
||||||
|
<sl-switch checked name="mouseDragging"> Mouse dragging </sl-switch>
|
||||||
|
</div>
|
||||||
|
<div class="carousel-options">
|
||||||
|
<sl-input type="number" label="Slides per page" name="slidesPerPage" value="1"></sl-input>
|
||||||
|
<sl-input type="number" label="Slides per move" name="slidesPerMove" value="1"></sl-input>
|
||||||
|
<sl-select label="Orientation" name="orientation" value="horizontal">
|
||||||
|
<sl-menu-item value="horizontal">Horizontal</sl-menu-item>
|
||||||
|
<sl-menu-item value="vertical">Vertical</sl-menu-item>
|
||||||
|
</sl-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
sl-carousel {
|
||||||
|
--aspect-ratio: 3 / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-options {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: end;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const options = document.querySelector('.carousel-options');
|
||||||
|
const carousel = document.querySelector('sl-carousel');
|
||||||
|
const loop = options.querySelector('sl-switch[name="loop"]');
|
||||||
|
const navigation = options.querySelector('sl-switch[name="navigation"]');
|
||||||
|
const pagination = options.querySelector('sl-switch[name="pagination"]');
|
||||||
|
const autoplay = options.querySelector('sl-switch[name="autoplay"]');
|
||||||
|
const mouseDragging = options.querySelector('sl-switch[name="mouseDragging"]');
|
||||||
|
const slidesPerMove = options.querySelector('sl-input[name="slidesPerMove"]');
|
||||||
|
const slidesPerPage = options.querySelector('sl-input[name="slidesPerPage"]');
|
||||||
|
const orientation = options.querySelector('sl-select[name="orientation"]');
|
||||||
|
|
||||||
|
loop.addEventListener('sl-change', () => (carousel.loop = loop.checked));
|
||||||
|
navigation.addEventListener('sl-change', () => (carousel.navigation = navigation.checked));
|
||||||
|
pagination.addEventListener('sl-change', () => (carousel.pagination = pagination.checked));
|
||||||
|
autoplay.addEventListener('sl-change', () => (carousel.autoplay = autoplay.checked));
|
||||||
|
slidesPerMove.addEventListener('sl-change', () => (carousel.slidesPerMove = slidesPerMove.valueAsNumber));
|
||||||
|
slidesPerPage.addEventListener('sl-change', () => (carousel.slidesPerPage = slidesPerPage.valueAsNumber));
|
||||||
|
orientation.addEventListener('sl-change', () => (carousel.orientation = orientation.value));
|
||||||
|
mouseDragging.addEventListener('sl-change', () => (carousel.mouseDragging = mouseDragging.checked));
|
||||||
|
|
||||||
|
document.addEventListener('sl-slide-change', e => {
|
||||||
|
console.log('Slide changed:', e.detail);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Multiple slides per view
|
||||||
|
|
||||||
|
Setting the attribute `slides-per-view` is it possible to specify how many items are shown at a given time.
|
||||||
|
Using this feature, it may be also useful to advance multiple slides at once, even though not strictly necessary.
|
||||||
|
This can be done by using the `slides-per-move` attribute.
|
||||||
|
|
||||||
|
```html preview
|
||||||
|
<sl-carousel class="multi-carousel" loop navigation pagination slides-per-page="3" slides-per-move="3">
|
||||||
|
<sl-carousel-item style="background: #204ed8;">Slide 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item style="background: #be133d;">Slide 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item style="background: #6e28d9;">Slide 3</sl-carousel-item>
|
||||||
|
<sl-carousel-item style="background: #c2420d;">Slide 4</sl-carousel-item>
|
||||||
|
<sl-carousel-item style="background: #4d7c0f;">Slide 5</sl-carousel-item>
|
||||||
|
<sl-carousel-item style="background: #4338cb;">Slide 6</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding/removing slides
|
||||||
|
|
||||||
|
The content of the carousel can be changed by either appending or removing items, the carousel will update itself automatically.
|
||||||
|
|
||||||
|
```html preview
|
||||||
|
<sl-carousel class="dynamic-carousel" pagination navigation>
|
||||||
|
<sl-carousel-item style="background: #204ed8;">Slide 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item style="background: #be133d;">Slide 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item style="background: #6e28d9;">Slide 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
<sl-divider></sl-divider>
|
||||||
|
<div class="carousel-options">
|
||||||
|
<sl-button id="dynamic-add">Add slide</sl-button>
|
||||||
|
<sl-button id="dynamic-remove">Remove slide</sl-button>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.dynamic-carousel {
|
||||||
|
--aspect-ratio: 3 / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dynamic-carousel sl-carousel-item {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: var(--sl-font-size-2x-large);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const dynamicCarousel = document.querySelector('.dynamic-carousel');
|
||||||
|
const dynamicAdd = document.querySelector('#dynamic-add');
|
||||||
|
const dynamicRemove = document.querySelector('#dynamic-remove');
|
||||||
|
|
||||||
|
const rnd = (min, max) => Math.round(Math.random() * (max - min)) + min;
|
||||||
|
|
||||||
|
const getRandomColor = () => `rgb(${rnd(50, 150)}, ${rnd(50, 150)}, ${rnd(50, 150)})`;
|
||||||
|
|
||||||
|
const addSlide = () => {
|
||||||
|
const slide = document.createElement('sl-carousel-item');
|
||||||
|
slide.innerText = `Slide ${dynamicCarousel.children.length + 1}`;
|
||||||
|
slide.style.setProperty('background', getRandomColor());
|
||||||
|
|
||||||
|
dynamicCarousel.appendChild(slide);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeSlide = () => {
|
||||||
|
const slide = dynamicCarousel.children[dynamicCarousel.children.length - 1];
|
||||||
|
slide.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
dynamicAdd.addEventListener('click', addSlide);
|
||||||
|
dynamicRemove.addEventListener('click', removeSlide);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vertical scrolling
|
||||||
|
|
||||||
|
Setting the `orientation` attribute to `vertical`, will make the carousel laying out vertically, making it
|
||||||
|
possible for the user to scroll it up and down. In case of heterogeneous content, for example images of different sizes,
|
||||||
|
it's important to specify a predefined height to the carousel through CSS.
|
||||||
|
|
||||||
|
```html preview
|
||||||
|
<sl-carousel class="vertical" loop pagination orientation="vertical">
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="The sun shines on the mountains and trees - Photo by Adam Kool on Unsplash"
|
||||||
|
src="/assets/examples/carousel/adam-kool-ndN00KmbJ1c-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="A waterfall in the middle of a forest - Photo by Thomas Kelly on Unsplash"
|
||||||
|
src="/assets/examples/carousel/thomas-kelley-JoH60FhTp50-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash"
|
||||||
|
src="/assets/examples/carousel/leonard-cotte-c1Jp-fo53U8-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="A field of grass with the sun setting in the background - Photo by Sapan Patel on Unsplash"
|
||||||
|
src="/assets/examples/carousel/sapan-patel-i9Q9bc-WgfE-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="A scenic view of a mountain with clouds rolling in - Photo by V2osk on Unsplash"
|
||||||
|
src="/assets/examples/carousel/v2osk-1Z2niiBPg5A-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
<style>
|
||||||
|
.vertical {
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical::part(base) {
|
||||||
|
grid-template-areas: 'slides slides pagination';
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical::part(pagination) {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vertical::part(navigation) {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Aspect ratio
|
||||||
|
|
||||||
|
Use the `--aspect-ratio` custom property to customize the size of viewport in order to make it match a particular aspect ratio.
|
||||||
|
|
||||||
|
```html preview
|
||||||
|
<sl-carousel class="aspect-ratio" navigation pagination style="--aspect-ratio: 3 / 2">
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="The sun shines on the mountains and trees - Photo by Adam Kool on Unsplash"
|
||||||
|
src="/assets/examples/carousel/adam-kool-ndN00KmbJ1c-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="A waterfall in the middle of a forest - Photo by Thomas Kelly on Unsplash"
|
||||||
|
src="/assets/examples/carousel/thomas-kelley-JoH60FhTp50-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash"
|
||||||
|
src="/assets/examples/carousel/leonard-cotte-c1Jp-fo53U8-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="A field of grass with the sun setting in the background - Photo by Sapan Patel on Unsplash"
|
||||||
|
src="/assets/examples/carousel/sapan-patel-i9Q9bc-WgfE-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="A scenic view of a mountain with clouds rolling in - Photo by V2osk on Unsplash"
|
||||||
|
src="/assets/examples/carousel/v2osk-1Z2niiBPg5A-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
|
||||||
|
<sl-divider></sl-divider>
|
||||||
|
<sl-select label="Aspect ratio" name="aspect" value="3 / 2">
|
||||||
|
<sl-menu-item value="1 / 1">1 / 1</sl-menu-item>
|
||||||
|
<sl-menu-item value="3 / 2">3 / 2</sl-menu-item>
|
||||||
|
<sl-menu-item value="16 / 9">16 / 9</sl-menu-item>
|
||||||
|
</sl-select>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const carousel = document.querySelector('sl-carousel.aspect-ratio');
|
||||||
|
const aspect = document.querySelector('sl-select[name="aspect"]');
|
||||||
|
|
||||||
|
aspect.addEventListener('sl-change', () => {
|
||||||
|
carousel.style.setProperty('--aspect-ratio', aspect.value);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scroll hint
|
||||||
|
|
||||||
|
Use `--scroll-padding` to add inline padding in horizontal carousels and block padding in vertical carousels.
|
||||||
|
Setting a padding, will make the closest slides visible, suggesting to the user that there are items that can
|
||||||
|
be scrolled.
|
||||||
|
|
||||||
|
```html preview
|
||||||
|
<sl-carousel class="scroll-hint" navigation pagination style="--scroll-padding: calc(var(--slide-gap) + 10%);">
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="The sun shines on the mountains and trees - Photo by Adam Kool on Unsplash"
|
||||||
|
src="/assets/examples/carousel/adam-kool-ndN00KmbJ1c-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="A waterfall in the middle of a forest - Photo by Thomas Kelly on Unsplash"
|
||||||
|
src="/assets/examples/carousel/thomas-kelley-JoH60FhTp50-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash"
|
||||||
|
src="/assets/examples/carousel/leonard-cotte-c1Jp-fo53U8-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="A field of grass with the sun setting in the background - Photo by Sapan Patel on Unsplash"
|
||||||
|
src="/assets/examples/carousel/sapan-patel-i9Q9bc-WgfE-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="A scenic view of a mountain with clouds rolling in - Photo by V2osk on Unsplash"
|
||||||
|
src="/assets/examples/carousel/v2osk-1Z2niiBPg5A-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
|
||||||
|
<sl-divider></sl-divider>
|
||||||
|
<sl-range label="Size (%)" name="scroll-hint" value="5" min="0" max="15"></sl-range>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const carousel = document.querySelector('sl-carousel.scroll-hint');
|
||||||
|
const scrollHint = document.querySelector('sl-range[name="scroll-hint"]');
|
||||||
|
|
||||||
|
scrollHint.addEventListener('sl-input', () => {
|
||||||
|
carousel.style.setProperty('--scroll-padding', `calc(var(--slide-gap) + ${scrollHint.value}%)`);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom layout
|
||||||
|
|
||||||
|
The appereance of the carousel can be easly customized through its slots or `part` attributes.
|
||||||
|
|
||||||
|
```html preview
|
||||||
|
<sl-carousel class="custom-layout" navigation pagination>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="The sun shines on the mountains and trees - Photo by Adam Kool on Unsplash"
|
||||||
|
src="/assets/examples/carousel/adam-kool-ndN00KmbJ1c-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="A waterfall in the middle of a forest - Photo by Thomas Kelly on Unsplash"
|
||||||
|
src="/assets/examples/carousel/thomas-kelley-JoH60FhTp50-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash"
|
||||||
|
src="/assets/examples/carousel/leonard-cotte-c1Jp-fo53U8-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="A field of grass with the sun setting in the background - Photo by Sapan Patel on Unsplash"
|
||||||
|
src="/assets/examples/carousel/sapan-patel-i9Q9bc-WgfE-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="A scenic view of a mountain with clouds rolling in - Photo by V2osk on Unsplash"
|
||||||
|
src="/assets/examples/carousel/v2osk-1Z2niiBPg5A-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
|
||||||
|
<sl-icon name="arrow-right" slot="next-icon"></sl-icon>
|
||||||
|
<sl-icon name="arrow-left" slot="previous-icon"></sl-icon>
|
||||||
|
</sl-carousel>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.custom-layout::part(base) {
|
||||||
|
grid-template-areas:
|
||||||
|
'slides slides slides'
|
||||||
|
'slides slides slides';
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-layout::part(pagination) {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--sl-spacing-large);
|
||||||
|
background: linear-gradient(0deg, rgba(0, 0, 0, 0.8) 5%, rgba(0, 0, 0, 0.2) 75%, rgba(0, 0, 0, 0) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-layout::part(pagination-item) {
|
||||||
|
height: 5px;
|
||||||
|
width: var(--sl-spacing-large);
|
||||||
|
border-radius: var(--sl-border-radius-pill);
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-layout::part(pagination-item--active) {
|
||||||
|
background-color: var(--sl-color-primary-400);
|
||||||
|
width: var(--sl-spacing-x-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-layout::part(navigation-button) {
|
||||||
|
margin: var(--sl-spacing-large);
|
||||||
|
border-radius: var(--sl-border-radius-circle);
|
||||||
|
font-weight: var(--sl-font-weight-bold);
|
||||||
|
color: var(--sl-color-neutral-1000);
|
||||||
|
background: var(--sl-color-neutral-0);
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: var(--sl-transition-medium) opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-layout::part(navigation-button):focus,
|
||||||
|
.custom-layout::part(navigation-button):hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gallery example
|
||||||
|
|
||||||
|
The carousel has a set of API with which is possible to interact programmatically, for example it is possible to
|
||||||
|
use `next()` or `previous()` to go respectively to the next or the previous slide.
|
||||||
|
|
||||||
|
When the active slide is changed, the `sl-slide-change` event is emitted providing the `index` of the slide.
|
||||||
|
|
||||||
|
Using the API is possible to extend the carousel, for exmaple by syncing the active slide with a set of thumbnails, like in the example below.
|
||||||
|
|
||||||
|
```html preview
|
||||||
|
<sl-carousel class="carousel-thumbnails" navigation loop>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="The sun shines on the mountains and trees - Photo by Adam Kool on Unsplash"
|
||||||
|
src="/assets/examples/carousel/adam-kool-ndN00KmbJ1c-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="A waterfall in the middle of a forest - Photo by Thomas Kelly on Unsplash"
|
||||||
|
src="/assets/examples/carousel/thomas-kelley-JoH60FhTp50-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash"
|
||||||
|
src="/assets/examples/carousel/leonard-cotte-c1Jp-fo53U8-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="A field of grass with the sun setting in the background - Photo by Sapan Patel on Unsplash"
|
||||||
|
src="/assets/examples/carousel/sapan-patel-i9Q9bc-WgfE-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
<sl-carousel-item>
|
||||||
|
<img
|
||||||
|
alt="A scenic view of a mountain with clouds rolling in - Photo by V2osk on Unsplash"
|
||||||
|
src="/assets/examples/carousel/v2osk-1Z2niiBPg5A-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
<div class="thumbnails">
|
||||||
|
<div class="thumbnails__scroller">
|
||||||
|
<img
|
||||||
|
alt="Thumbnail Photo by 1"
|
||||||
|
class="thumbnails__image active"
|
||||||
|
src="/assets/examples/carousel/adam-kool-ndN00KmbJ1c-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
alt="Thumbnail Photo by 2"
|
||||||
|
class="thumbnails__image"
|
||||||
|
Jeff
|
||||||
|
King
|
||||||
|
on
|
||||||
|
Unsplash
|
||||||
|
src="/assets/examples/carousel/thomas-kelley-JoH60FhTp50-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
alt="Thumbnail Photo by 3"
|
||||||
|
class="thumbnails__image"
|
||||||
|
Leonard
|
||||||
|
Cotte
|
||||||
|
on
|
||||||
|
Unsplash
|
||||||
|
src="/assets/examples/carousel/leonard-cotte-c1Jp-fo53U8-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
alt="Thumbnail Photo by 4"
|
||||||
|
class="thumbnails__image"
|
||||||
|
Lukasz
|
||||||
|
Szmigiel
|
||||||
|
on
|
||||||
|
Unsplash
|
||||||
|
src="/assets/examples/carousel/sapan-patel-i9Q9bc-WgfE-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
alt="Thumbnail Photo by 5"
|
||||||
|
class="thumbnails__image"
|
||||||
|
V2osk
|
||||||
|
on
|
||||||
|
Unsplash
|
||||||
|
src="/assets/examples/carousel/v2osk-1Z2niiBPg5A-unsplash.jpg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<style>
|
||||||
|
.carousel-thumbnails {
|
||||||
|
--slide-aspect-ratio: 3 / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnails {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnails__scroller {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sl-spacing-small);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scroll-padding: var(--sl-spacing-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnails__scroller::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnails__image {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: cover;
|
||||||
|
|
||||||
|
opacity: 0.3;
|
||||||
|
will-change: opacity;
|
||||||
|
transition: 250ms opacity;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnails__image.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
{
|
||||||
|
const carousel = document.querySelector('.carousel-thumbnails');
|
||||||
|
const scroller = document.querySelector('.thumbnails__scroller');
|
||||||
|
const thumbnails = document.querySelectorAll('.thumbnails__image');
|
||||||
|
|
||||||
|
scroller.addEventListener('click', e => {
|
||||||
|
const target = e.target;
|
||||||
|
|
||||||
|
if (target.matches('.thumbnails__image')) {
|
||||||
|
const index = [...thumbnails].indexOf(target);
|
||||||
|
carousel.goToSlide(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
carousel.addEventListener('sl-slide-change', e => {
|
||||||
|
const slideIndex = e.detail.index;
|
||||||
|
|
||||||
|
[...thumbnails].forEach((thumb, i) => {
|
||||||
|
thumb.classList.toggle('active', i === slideIndex);
|
||||||
|
if (i === slideIndex) {
|
||||||
|
thumb.scrollIntoView({
|
||||||
|
block: 'nearest'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
[component-metadata:sl-carousel]
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { css } from 'lit';
|
||||||
|
import componentStyles from '../../styles/component.styles';
|
||||||
|
|
||||||
|
export default css`
|
||||||
|
${componentStyles}
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
scroll-snap-align: start;
|
||||||
|
scroll-snap-stop: always;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
|
||||||
|
aspect-ratio: var(--aspect-ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
::slotted(img) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
`;
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { expect, fixture, html } from '@open-wc/testing';
|
||||||
|
|
||||||
|
describe('<sl-carousel-item>', () => {
|
||||||
|
it('should render a component', async () => {
|
||||||
|
const el = await fixture(html` <sl-carousel-item></sl-carousel-item> `);
|
||||||
|
|
||||||
|
expect(el).to.exist;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass accessibility tests', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture(html` <div role="list"><sl-carousel-item></sl-carousel-item></div> `);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expect(el).to.be.accessible();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { html } from 'lit';
|
||||||
|
import { customElement } from 'lit/decorators.js';
|
||||||
|
import ShoelaceElement from '../../internal/shoelace-element';
|
||||||
|
import styles from './carousel-item.styles';
|
||||||
|
import type { CSSResultGroup } from 'lit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary A carousel item represent a slide within a [carousel](/components/carousel).
|
||||||
|
*
|
||||||
|
* @since 2.0
|
||||||
|
* @status experimental
|
||||||
|
*
|
||||||
|
* @slot - The carousel item's content..
|
||||||
|
*
|
||||||
|
* @cssproperty --aspect-ratio - The aspect ratio of the slide.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@customElement('sl-carousel-item')
|
||||||
|
export default class SlCarouselItem extends ShoelaceElement {
|
||||||
|
static styles: CSSResultGroup = styles;
|
||||||
|
|
||||||
|
static isCarouselItem(node: Node) {
|
||||||
|
return node instanceof Element && node.getAttribute('aria-roledescription') === 'slide';
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
this.setAttribute('role', 'listitem');
|
||||||
|
this.setAttribute('aria-roledescription', 'slide');
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html` <slot></slot> `;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'sl-carousel-item': SlCarouselItem;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,68 @@
|
||||||
|
import type { ReactiveController, ReactiveElement } from 'lit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A controller that repeatedly calls the specified callback with the provided interval time.
|
||||||
|
* The timer is automatically paused while the user is interacting with the component.
|
||||||
|
*/
|
||||||
|
export class AutoplayController implements ReactiveController {
|
||||||
|
private host: ReactiveElement;
|
||||||
|
private timerId = 0;
|
||||||
|
private tickCallback: () => void;
|
||||||
|
|
||||||
|
paused = false;
|
||||||
|
stopped = true;
|
||||||
|
|
||||||
|
constructor(host: ReactiveElement, tickCallback: () => void) {
|
||||||
|
host.addController(this);
|
||||||
|
|
||||||
|
this.host = host;
|
||||||
|
this.tickCallback = tickCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
hostConnected(): void {
|
||||||
|
this.host.addEventListener('mouseenter', this.pause);
|
||||||
|
this.host.addEventListener('mouseleave', this.resume);
|
||||||
|
this.host.addEventListener('focusin', this.pause);
|
||||||
|
this.host.addEventListener('focusout', this.resume);
|
||||||
|
this.host.addEventListener('touchstart', this.pause, { passive: true });
|
||||||
|
this.host.addEventListener('touchend', this.resume);
|
||||||
|
}
|
||||||
|
|
||||||
|
hostDisconnected(): void {
|
||||||
|
this.stop();
|
||||||
|
|
||||||
|
this.host.removeEventListener('mouseenter', this.pause);
|
||||||
|
this.host.removeEventListener('mouseleave', this.resume);
|
||||||
|
this.host.removeEventListener('focusin', this.pause);
|
||||||
|
this.host.removeEventListener('focusout', this.resume);
|
||||||
|
this.host.removeEventListener('touchstart', this.pause);
|
||||||
|
this.host.removeEventListener('touchend', this.resume);
|
||||||
|
}
|
||||||
|
|
||||||
|
start(interval: number) {
|
||||||
|
this.stop();
|
||||||
|
|
||||||
|
this.stopped = false;
|
||||||
|
this.timerId = window.setInterval(() => {
|
||||||
|
if (!this.paused) {
|
||||||
|
this.tickCallback();
|
||||||
|
}
|
||||||
|
}, interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
clearInterval(this.timerId);
|
||||||
|
this.stopped = true;
|
||||||
|
this.host.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
pause = () => {
|
||||||
|
this.paused = true;
|
||||||
|
this.host.requestUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
resume = () => {
|
||||||
|
this.paused = false;
|
||||||
|
this.host.requestUpdate();
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { css } from 'lit';
|
||||||
|
import componentStyles from '../../styles/component.styles';
|
||||||
|
|
||||||
|
export default css`
|
||||||
|
${componentStyles}
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
--slide-gap: var(--sl-spacing-medium, 1rem);
|
||||||
|
--aspect-ratio: unset;
|
||||||
|
--scroll-padding: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel {
|
||||||
|
min-height: 100%;
|
||||||
|
min-width: 100%;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
|
||||||
|
gap: var(--sl-spacing-medium);
|
||||||
|
|
||||||
|
grid-template-columns: min-content 1fr min-content;
|
||||||
|
grid-template-rows: 1fr min-content;
|
||||||
|
grid-template-areas:
|
||||||
|
'. slides .'
|
||||||
|
'. pagination .';
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__pagination {
|
||||||
|
grid-area: pagination;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--sl-spacing-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__slides {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
grid-area: slides;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
justify-items: center;
|
||||||
|
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
aspect-ratio: calc(var(--aspect-ratio) * var(--slides-per-page));
|
||||||
|
|
||||||
|
--slide-size: calc((100% - (var(--slides-per-page) - 1) * var(--slide-gap)) / var(--slides-per-page));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion) {
|
||||||
|
:where(.carousel__slides) {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__slides--horizontal {
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: var(--slide-size);
|
||||||
|
grid-auto-rows: 100%;
|
||||||
|
column-gap: var(--slide-gap);
|
||||||
|
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
|
||||||
|
scroll-padding-inline: var(--scroll-padding);
|
||||||
|
padding-inline: var(--scroll-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__slides--vertical {
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-auto-columns: 100%;
|
||||||
|
grid-auto-rows: var(--slide-size);
|
||||||
|
row-gap: var(--slide-gap);
|
||||||
|
|
||||||
|
scroll-snap-type: y mandatory;
|
||||||
|
|
||||||
|
scroll-padding-block: var(--scroll-padding);
|
||||||
|
padding-block: var(--scroll-padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__slides--dragging,
|
||||||
|
.carousel__slides--dropping {
|
||||||
|
scroll-snap-type: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([vertical]) ::slotted(sl-carousel-item) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__slides::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__navigation {
|
||||||
|
grid-area: navigation;
|
||||||
|
display: contents;
|
||||||
|
|
||||||
|
font-size: var(--sl-font-size-x-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__navigation-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--sl-border-radius-medium);
|
||||||
|
font-size: inherit;
|
||||||
|
color: var(--sl-color-neutral-600);
|
||||||
|
padding: var(--sl-spacing-x-small);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--sl-transition-medium) color;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__navigation-button--disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__navigation-button--disabled::part(base) {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__navigation-button--previous {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__navigation-button--next {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__pagination-item {
|
||||||
|
display: block;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--sl-border-radius-circle);
|
||||||
|
width: var(--sl-spacing-small);
|
||||||
|
height: var(--sl-spacing-small);
|
||||||
|
background-color: var(--sl-color-neutral-300);
|
||||||
|
will-change: transform;
|
||||||
|
transition: var(--sl-transition-fast) ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel__pagination-item--active {
|
||||||
|
background-color: var(--sl-color-neutral-600);
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
`;
|
|
@ -0,0 +1,601 @@
|
||||||
|
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
|
||||||
|
import sinon from 'sinon';
|
||||||
|
import { clickOnElement } from '../../internal/test';
|
||||||
|
import type SlCarousel from './carousel';
|
||||||
|
|
||||||
|
describe('<sl-carousel>', () => {
|
||||||
|
it('should render a carousel with default configuration', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture(html`
|
||||||
|
<sl-carousel>
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(el).to.exist;
|
||||||
|
expect(el).to.have.attribute('role', 'region');
|
||||||
|
expect(el).to.have.attribute('aria-roledescription', 'carousel');
|
||||||
|
expect(el.shadowRoot!.querySelector('.carousel__navigation')).not.to.exist;
|
||||||
|
expect(el.shadowRoot!.querySelector('.carousel__pagination')).not.to.exist;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when `autoplay` attribute is provided', () => {
|
||||||
|
let clock: sinon.SinonFakeTimers;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clock = sinon.useFakeTimers({
|
||||||
|
now: new Date()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should scroll forwards every `autoplay-interval` milliseconds', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel autoplay autoplay-interval="10">
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
sinon.stub(el, 'next');
|
||||||
|
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
clock.next();
|
||||||
|
clock.next();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(el.next).to.have.been.calledTwice;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pause the autoplay while the user is interacting', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel autoplay autoplay-interval="10">
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
sinon.stub(el, 'next');
|
||||||
|
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
el.dispatchEvent(new Event('mouseenter'));
|
||||||
|
await el.updateComplete;
|
||||||
|
clock.next();
|
||||||
|
clock.next();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(el.next).not.to.have.been.called;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when `loop` attribute is provided', () => {
|
||||||
|
it('should create clones of the first and last slides', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel loop>
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(el.firstElementChild).to.have.attribute('data-clone', '2');
|
||||||
|
expect(el.lastElementChild).to.have.attribute('data-clone', '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and `slides-per-page` is provided', () => {
|
||||||
|
it('should create multiple clones', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel loop slides-per-page="2">
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await el.updateComplete;
|
||||||
|
const clones = [...el.children].filter(child => child.hasAttribute('data-clone'));
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(clones).to.have.lengthOf(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when `pagination` attribute is provided', () => {
|
||||||
|
it('should render pagination controls', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture(html`
|
||||||
|
<sl-carousel pagination>
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(el).to.exist;
|
||||||
|
expect(el.shadowRoot!.querySelector('.carousel__navigation')).not.to.exist;
|
||||||
|
expect(el.shadowRoot!.querySelector('.carousel__pagination')).to.exist;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and user clicks on a pagination button', () => {
|
||||||
|
it('should scroll the carousel to the nth slide', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel pagination>
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
sinon.stub(el, 'goToSlide');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const paginationItem = el.shadowRoot!.querySelectorAll('.carousel__pagination-item')[2] as HTMLElement;
|
||||||
|
await clickOnElement(paginationItem);
|
||||||
|
|
||||||
|
expect(el.goToSlide).to.have.been.calledWith(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when `navigation` attribute is provided', () => {
|
||||||
|
it('should render navigation controls', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture(html`
|
||||||
|
<sl-carousel navigation>
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(el).to.exist;
|
||||||
|
expect(el.shadowRoot!.querySelector('.carousel__navigation')).to.exist;
|
||||||
|
expect(el.shadowRoot!.querySelector('.carousel__pagination')).not.to.exist;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when `slides-per-page` attribute is provided', () => {
|
||||||
|
it('should show multiple slides at a given time', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel slides-per-page="2">
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(el.scrollContainer.style.getPropertyValue('--slides-per-page')).to.be.equal('2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when `slides-per-move` attribute is provided', () => {
|
||||||
|
it('should set the granularity of snapping', async () => {
|
||||||
|
// Arrange
|
||||||
|
const expectedSnapGranularity = 2;
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel slides-per-move="${expectedSnapGranularity}">
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 4</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
for (let i = 0; i < el.children.length; i++) {
|
||||||
|
const child = el.children[i] as HTMLElement;
|
||||||
|
|
||||||
|
if (i % expectedSnapGranularity === 0) {
|
||||||
|
expect(child.style.getPropertyValue('scroll-snap-align')).to.be.equal('');
|
||||||
|
} else {
|
||||||
|
expect(child.style.getPropertyValue('scroll-snap-align')).to.be.equal('none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when `orientation` attribute is provided', () => {
|
||||||
|
describe('and value is `vertical`', () => {
|
||||||
|
it('should make the scrollable along the y-axis', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel orientation="vertical" style="height: 100px">
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(el.scrollContainer.scrollWidth).to.be.equal(el.scrollContainer.clientWidth);
|
||||||
|
expect(el.scrollContainer.scrollHeight).to.be.greaterThan(el.scrollContainer.clientHeight);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and value is `horizontal`', () => {
|
||||||
|
it('should make the scrollable along the x-axis', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel orientation="horizontal" style="height: 100px">
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(el.scrollContainer.scrollWidth).to.be.greaterThan(el.scrollContainer.clientWidth);
|
||||||
|
expect(el.scrollContainer.scrollHeight).to.be.equal(el.scrollContainer.clientHeight);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Navigation controls', () => {
|
||||||
|
describe('when the user clicks the next button', () => {
|
||||||
|
it('should scroll to the next slide', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel navigation>
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
const nextButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--next')!;
|
||||||
|
sinon.stub(el, 'next');
|
||||||
|
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await clickOnElement(nextButton);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(el.next).to.have.been.calledOnce;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and carousel is positioned on the last slide', () => {
|
||||||
|
it('should not scroll', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel navigation>
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
const nextButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--next')!;
|
||||||
|
sinon.stub(el, 'next');
|
||||||
|
|
||||||
|
el.goToSlide(2, 'auto');
|
||||||
|
await oneEvent(el.scrollContainer, 'scrollend');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await clickOnElement(nextButton);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(nextButton).to.have.attribute('aria-disabled', 'true');
|
||||||
|
expect(el.next).not.to.have.been.called;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and `loop` attribute is provided', () => {
|
||||||
|
it('should scroll to the first slide', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel navigation loop>
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
const nextButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--next')!;
|
||||||
|
|
||||||
|
el.goToSlide(2, 'auto');
|
||||||
|
await oneEvent(el.scrollContainer, 'scrollend');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await clickOnElement(nextButton);
|
||||||
|
|
||||||
|
// wait first scroll to clone
|
||||||
|
await oneEvent(el.scrollContainer, 'scrollend');
|
||||||
|
// wait scroll to actual item
|
||||||
|
await oneEvent(el.scrollContainer, 'scrollend');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(nextButton).to.have.attribute('aria-disabled', 'false');
|
||||||
|
expect(el.activeSlide).to.be.equal(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and clicks the previous button', () => {
|
||||||
|
it('should scroll to the previous slide', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel navigation>
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Go to the second slide so that the previous button will be enabled
|
||||||
|
el.goToSlide(1, 'auto');
|
||||||
|
await oneEvent(el.scrollContainer, 'scrollend');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
const previousButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--previous')!;
|
||||||
|
sinon.stub(el, 'previous');
|
||||||
|
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await clickOnElement(previousButton);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(el.previous).to.have.been.calledOnce;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and carousel is positioned on the first slide', () => {
|
||||||
|
it('should not scroll', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel navigation>
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const previousButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--previous')!;
|
||||||
|
sinon.stub(el, 'previous');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await clickOnElement(previousButton);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(previousButton).to.have.attribute('aria-disabled', 'true');
|
||||||
|
expect(el.previous).not.to.have.been.called;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and `loop` attribute is provided', () => {
|
||||||
|
it('should scroll to the last slide', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel navigation loop>
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const previousButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--previous')!;
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await clickOnElement(previousButton);
|
||||||
|
|
||||||
|
// wait first scroll to clone
|
||||||
|
await oneEvent(el.scrollContainer, 'scrollend');
|
||||||
|
// wait scroll to actual item
|
||||||
|
await oneEvent(el.scrollContainer, 'scrollend');
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(previousButton).to.have.attribute('aria-disabled', 'false');
|
||||||
|
expect(el.activeSlide).to.be.equal(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('API', () => {
|
||||||
|
describe('#next', () => {
|
||||||
|
it('should scroll the carousel to the next slide', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel slides-per-move="2">
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
sinon.stub(el, 'goToSlide');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
el.next();
|
||||||
|
|
||||||
|
expect(el.goToSlide).to.have.been.calledWith(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#previous', () => {
|
||||||
|
it('should scroll the carousel to the previous slide', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel slides-per-move="2">
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
sinon.stub(el, 'goToSlide');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
el.previous();
|
||||||
|
|
||||||
|
expect(el.goToSlide).to.have.been.calledWith(-2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#goToSlide', () => {
|
||||||
|
it('should scroll the carousel to the nth slide', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel>
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
el.goToSlide(2);
|
||||||
|
await oneEvent(el.scrollContainer, 'scrollend');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(el.activeSlide).to.be.equal(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('should pass accessibility tests', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel navigation pagination>
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
const pagination = el.shadowRoot!.querySelector('.carousel__pagination')!;
|
||||||
|
const navigation = el.shadowRoot!.querySelector('.carousel__navigation')!;
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(el.scrollContainer).to.have.attribute('aria-busy', 'false');
|
||||||
|
expect(el.scrollContainer).to.have.attribute('aria-live', 'polite');
|
||||||
|
expect(el.scrollContainer).to.have.attribute('aria-atomic', 'true');
|
||||||
|
|
||||||
|
expect(pagination).to.have.attribute('role', 'tablist');
|
||||||
|
expect(pagination).to.have.attribute('aria-controls', el.scrollContainer.id);
|
||||||
|
for (const paginationItem of pagination.querySelectorAll('.carousel__pagination-item')) {
|
||||||
|
expect(paginationItem).to.have.attribute('role', 'tab');
|
||||||
|
expect(paginationItem).to.have.attribute('aria-selected');
|
||||||
|
expect(paginationItem).to.have.attribute('aria-label');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const navigationItem of navigation.querySelectorAll('.carousel__navigation-item')) {
|
||||||
|
expect(navigationItem).to.have.attribute('aria-controls', el.scrollContainer.id);
|
||||||
|
expect(navigationItem).to.have.attribute('aria-disabled');
|
||||||
|
expect(navigationItem).to.have.attribute('aria-label');
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(el).to.be.accessible();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when scrolling', () => {
|
||||||
|
it('should update aria-busy attribute', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel autoplay>
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
el.goToSlide(2, 'smooth');
|
||||||
|
await oneEvent(el.scrollContainer, 'scroll');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(el.scrollContainer).to.have.attribute('aria-busy', 'true');
|
||||||
|
|
||||||
|
await oneEvent(el.scrollContainer, 'scrollend');
|
||||||
|
await el.updateComplete;
|
||||||
|
expect(el.scrollContainer).to.have.attribute('aria-busy', 'false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when autoplay is active', () => {
|
||||||
|
it('should disable live announcement', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel autoplay>
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(el.scrollContainer).to.have.attribute('aria-live', 'off');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('and user is interacting with the carousel', () => {
|
||||||
|
it('should enable live announcement', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel autoplay>
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
el.dispatchEvent(new Event('focusin'));
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(el.scrollContainer).to.have.attribute('aria-live', 'polite');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,435 @@
|
||||||
|
import { LocalizeController } from '@shoelace-style/localize';
|
||||||
|
import { html } from 'lit';
|
||||||
|
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||||
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
|
import { map } from 'lit/directives/map.js';
|
||||||
|
import { range } from 'lit/directives/range.js';
|
||||||
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
|
import { when } from 'lit/directives/when.js';
|
||||||
|
import { clamp } from 'src/internal/math';
|
||||||
|
import { prefersReducedMotion } from '../../internal/animate';
|
||||||
|
import ShoelaceElement from '../../internal/shoelace-element';
|
||||||
|
import { watch } from '../../internal/watch';
|
||||||
|
import SlCarouselItem from '../carousel-item/carousel-item';
|
||||||
|
import '../icon/icon';
|
||||||
|
import { AutoplayController } from './autoplay-controller';
|
||||||
|
import styles from './carousel.styles';
|
||||||
|
import { ScrollController } from './scroll-controller';
|
||||||
|
import type { CSSResultGroup } from 'lit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary A generic carousel used for displaying an arbitrary number of `sl-carousel-item` along horizontal or vertical axis.
|
||||||
|
*
|
||||||
|
* @since 2.0
|
||||||
|
* @status experimental
|
||||||
|
*
|
||||||
|
* @dependency sl-icon
|
||||||
|
*
|
||||||
|
* @event {{ index: number, slide: SlCarouselItem }} sl-slide-change - Emitted when the active slide changes.
|
||||||
|
*
|
||||||
|
* @slot - The carousel's main content, where `sl-carousel-item`s are placed.
|
||||||
|
* @slot next-icon - Optional next icon to use instead of the default. Works best with `<sl-icon>`.
|
||||||
|
* @slot previous-icon - Optional previous icon to use instead of the default. Works best with `<sl-icon>`.
|
||||||
|
*
|
||||||
|
* @csspart base - The carousel's internal wrapper.
|
||||||
|
* @csspart scroll-container - The scroll container that wraps the slides.
|
||||||
|
* @csspart pagination - The pagination indicators wrapper.
|
||||||
|
* @csspart pagination-item - The pagination indicator.
|
||||||
|
* @csspart pagination-item--active - Applied when the item is active.
|
||||||
|
* @csspart navigation - The navigation wrapper.
|
||||||
|
* @csspart navigation-button - The navigation button.
|
||||||
|
* @csspart navigation-button--previous - Applied to the previous button.
|
||||||
|
* @csspart navigation-button--next - Applied to the next button.
|
||||||
|
*
|
||||||
|
* @cssproperty --slide-gap - The space between each slide.
|
||||||
|
* @cssproperty --aspect-ratio - The aspect ratio of each slide.
|
||||||
|
* @cssproperty --scroll-padding - The amount of padding to apply to the scrollport, useful to make visible the closest slides.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@customElement('sl-carousel')
|
||||||
|
export default class SlCarousel extends ShoelaceElement {
|
||||||
|
static styles: CSSResultGroup = styles;
|
||||||
|
|
||||||
|
/** When set, allows the user to navigate the carousel in the same direction indefinitely. */
|
||||||
|
@property({ type: Boolean, reflect: true }) loop = false;
|
||||||
|
|
||||||
|
/** When set, show the carousel's navigation. */
|
||||||
|
@property({ type: Boolean, reflect: true }) navigation = false;
|
||||||
|
|
||||||
|
/** When set, show the carousel's pagination indicators. */
|
||||||
|
@property({ type: Boolean, reflect: true }) pagination = false;
|
||||||
|
|
||||||
|
/** When set, the slides will scroll automatically when the user is not interacting with them. */
|
||||||
|
@property({ type: Boolean, reflect: true }) autoplay = false;
|
||||||
|
|
||||||
|
/** Specifies the amount of time, in milliseconds, between each automatic scroll. */
|
||||||
|
@property({ type: Number, attribute: 'autoplay-interval' }) autoplayInterval = 3000;
|
||||||
|
|
||||||
|
/** Specifies how many slides should be shown at a given time. */
|
||||||
|
@property({ type: Number, attribute: 'slides-per-page' }) slidesPerPage = 1;
|
||||||
|
|
||||||
|
/** Specifies the number of slides the carousel will advance when scrolling, useful when specifying a `slides-per-page` greather than one. */
|
||||||
|
@property({ type: Number, attribute: 'slides-per-move' }) slidesPerMove = 1;
|
||||||
|
|
||||||
|
/** Specifies the orientation in which the carousel will lay out. */
|
||||||
|
@property() orientation: 'horizontal' | 'vertical' = 'horizontal';
|
||||||
|
|
||||||
|
/** When set, it is possible to scroll through the slides by dragging them with the mouse. */
|
||||||
|
@property({ type: Boolean, reflect: true, attribute: 'mouse-dragging' }) mouseDragging = false;
|
||||||
|
|
||||||
|
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
|
||||||
|
@query('.carousel__slides') scrollContainer: HTMLElement;
|
||||||
|
@query('.carousel__pagination') paginationContainer: HTMLElement;
|
||||||
|
|
||||||
|
// The index of the active slide
|
||||||
|
@state() activeSlide = 0;
|
||||||
|
|
||||||
|
private autoplayController = new AutoplayController(this, () => {
|
||||||
|
this.next();
|
||||||
|
});
|
||||||
|
private scrollController = new ScrollController(this);
|
||||||
|
|
||||||
|
private readonly slides = this.getElementsByTagName('sl-carousel-item');
|
||||||
|
|
||||||
|
// The interseection observer is used to determine which slide is displayed
|
||||||
|
private intersectionObserver: IntersectionObserver;
|
||||||
|
|
||||||
|
// A map containig the state of all the slides
|
||||||
|
private readonly intersectionObserverEntries = new Map<Element, IntersectionObserverEntry>();
|
||||||
|
|
||||||
|
private readonly localize = new LocalizeController(this);
|
||||||
|
private mutationObserver: MutationObserver;
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
this.setAttribute('role', 'region');
|
||||||
|
this.setAttribute('aria-roledescription', 'carousel');
|
||||||
|
|
||||||
|
const intersectionObserver = new IntersectionObserver(
|
||||||
|
(entries: IntersectionObserverEntry[]) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
// Store all the entries in a map to be processed when the scroll ends
|
||||||
|
this.intersectionObserverEntries.set(entry.target, entry);
|
||||||
|
|
||||||
|
const slide = entry.target;
|
||||||
|
slide.toggleAttribute('inert', !entry.isIntersecting);
|
||||||
|
slide.classList.toggle('--in-view', entry.isIntersecting);
|
||||||
|
slide.setAttribute('aria-hidden', entry.isIntersecting ? 'false' : 'true');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: this,
|
||||||
|
threshold: 0.6
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.intersectionObserver = intersectionObserver;
|
||||||
|
|
||||||
|
// Store the initial state of each slide
|
||||||
|
intersectionObserver.takeRecords().forEach(entry => {
|
||||||
|
this.intersectionObserverEntries.set(entry.target, entry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this.intersectionObserver.disconnect();
|
||||||
|
this.mutationObserver.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected firstUpdated(): void {
|
||||||
|
this.initialiseSlides();
|
||||||
|
this.mutationObserver = new MutationObserver(this.handleSlotChange.bind(this));
|
||||||
|
this.mutationObserver.observe(this, { childList: true, subtree: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSlides({ excludeClones = true }: { excludeClones?: boolean } = {}) {
|
||||||
|
return [...this.slides].filter(slide => !excludeClones || !slide.hasAttribute('data-clone'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the carousel backwards by `slides-per-move` slides.
|
||||||
|
*
|
||||||
|
* @param behavior - The behavior used for scrolling.
|
||||||
|
*/
|
||||||
|
previous(behavior: ScrollBehavior = 'smooth') {
|
||||||
|
this.goToSlide(this.activeSlide - this.slidesPerMove, behavior);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move the carousel forwards by `slides-per-move` slides.
|
||||||
|
*
|
||||||
|
* @param behavior - The behavior used for scrolling.
|
||||||
|
*/
|
||||||
|
next(behavior: ScrollBehavior = 'smooth') {
|
||||||
|
this.goToSlide(this.activeSlide + this.slidesPerMove, behavior);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls the carousel to the slide specified by `index`.
|
||||||
|
*
|
||||||
|
* @param index - The slide index.
|
||||||
|
* @param behavior - The behavior used for scrolling.
|
||||||
|
*/
|
||||||
|
goToSlide(index: number, behavior: ScrollBehavior = 'smooth') {
|
||||||
|
const { slidesPerPage, loop } = this;
|
||||||
|
|
||||||
|
const slidesWithClones = this.getSlides({ excludeClones: false });
|
||||||
|
const normalizedIndex = clamp(index + (loop ? slidesPerPage : 0), 0, slidesWithClones.length - 1);
|
||||||
|
const slide = slidesWithClones[normalizedIndex];
|
||||||
|
|
||||||
|
this.scrollContainer.scrollTo({
|
||||||
|
left: slide.offsetLeft,
|
||||||
|
top: slide.offsetTop,
|
||||||
|
behavior: prefersReducedMotion() ? 'auto' : behavior
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSlotChange(mutations: MutationRecord[]) {
|
||||||
|
const needsInitialisation = mutations.some(mutation =>
|
||||||
|
[...mutation.addedNodes, ...mutation.removedNodes].some(
|
||||||
|
node => SlCarouselItem.isCarouselItem(node) && !(node as HTMLElement).hasAttribute('data-clone')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reinitialise the carousel if a carousel item has been added and/or removed
|
||||||
|
if (needsInitialisation) {
|
||||||
|
this.initialiseSlides();
|
||||||
|
this.requestUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleScrollEnd() {
|
||||||
|
const slides = this.getSlides();
|
||||||
|
const entries = [...this.intersectionObserverEntries.values()];
|
||||||
|
|
||||||
|
const firstIntersecting: IntersectionObserverEntry | undefined = entries.find(entry => entry.isIntersecting);
|
||||||
|
|
||||||
|
if (this.loop && firstIntersecting?.target.hasAttribute('data-clone')) {
|
||||||
|
const clonePosition = Number(firstIntersecting.target.getAttribute('data-clone'));
|
||||||
|
// Scrolls to the original slide without animating, so the user won't notice that the position has changed
|
||||||
|
this.goToSlide(clonePosition, 'auto');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate the first inetersecting slide
|
||||||
|
if (firstIntersecting) {
|
||||||
|
this.activeSlide = slides.indexOf(firstIntersecting.target as SlCarouselItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@watch('loop', { waitUntilFirstUpdate: true })
|
||||||
|
@watch('slidesPerPage', { waitUntilFirstUpdate: true })
|
||||||
|
initialiseSlides() {
|
||||||
|
const slides = this.getSlides();
|
||||||
|
const intersectionObserver = this.intersectionObserver;
|
||||||
|
|
||||||
|
this.intersectionObserverEntries.clear();
|
||||||
|
|
||||||
|
// Removes all the cloned elements from the carousel
|
||||||
|
this.getSlides({ excludeClones: false }).forEach(slide => {
|
||||||
|
intersectionObserver.unobserve(slide);
|
||||||
|
|
||||||
|
slide.classList.remove('--in-view');
|
||||||
|
slide.classList.remove('--is-active');
|
||||||
|
|
||||||
|
if (slide.hasAttribute('data-clone')) {
|
||||||
|
slide.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.loop) {
|
||||||
|
// Creates clones to be placed before and after the original elements
|
||||||
|
// so that it will be possible to simulate an infinite scrolling
|
||||||
|
const slidesPerPage = this.slidesPerPage;
|
||||||
|
const lastSlides = slides.slice(-slidesPerPage);
|
||||||
|
const firstSlides = slides.slice(0, slidesPerPage);
|
||||||
|
|
||||||
|
lastSlides.reverse().forEach((slide, i) => {
|
||||||
|
const clone = slide.cloneNode(true) as HTMLElement;
|
||||||
|
clone.setAttribute('data-clone', String(slides.length - i - 1));
|
||||||
|
this.prepend(clone);
|
||||||
|
});
|
||||||
|
|
||||||
|
firstSlides.forEach((slide, i) => {
|
||||||
|
const clone = slide.cloneNode(true) as HTMLElement;
|
||||||
|
clone.setAttribute('data-clone', String(i));
|
||||||
|
this.append(clone);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.getSlides({ excludeClones: false }).forEach(slide => {
|
||||||
|
intersectionObserver.observe(slide);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Because the dom may be changed, restore the scroll position to the active slide
|
||||||
|
this.goToSlide(this.activeSlide, 'auto');
|
||||||
|
}
|
||||||
|
|
||||||
|
@watch('activeSlide')
|
||||||
|
handelSlideChange() {
|
||||||
|
const slides = this.getSlides();
|
||||||
|
slides.forEach((slide, i) => {
|
||||||
|
slide.classList.toggle('--is-active', i === this.activeSlide);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Do not fire any event on first render
|
||||||
|
if (this.hasUpdated) {
|
||||||
|
this.emit('sl-slide-change', {
|
||||||
|
detail: {
|
||||||
|
index: this.activeSlide,
|
||||||
|
slide: slides[this.activeSlide]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@watch('slidesPerMove')
|
||||||
|
handleSlidesPerMoveChange() {
|
||||||
|
const slides = this.getSlides({ excludeClones: false });
|
||||||
|
|
||||||
|
const slidesPerMove = this.slidesPerMove;
|
||||||
|
slides.forEach((slide, i) => {
|
||||||
|
const shouldSnap = Math.abs(i - slidesPerMove) % slidesPerMove === 0;
|
||||||
|
if (shouldSnap) {
|
||||||
|
slide.style.removeProperty('scroll-snap-align');
|
||||||
|
} else {
|
||||||
|
slide.style.setProperty('scroll-snap-align', 'none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@watch('autoplay')
|
||||||
|
handleAutoplayChange() {
|
||||||
|
this.autoplayController.stop();
|
||||||
|
if (this.autoplay) {
|
||||||
|
this.autoplayController.start(this.autoplayInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@watch('mouseDragging')
|
||||||
|
handleMouseDraggingChange() {
|
||||||
|
this.scrollController.mouseDragging = this.mouseDragging;
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderPagination = () => {
|
||||||
|
const slides = this.getSlides();
|
||||||
|
const slidesCount = slides.length;
|
||||||
|
|
||||||
|
const { activeSlide, slidesPerPage } = this;
|
||||||
|
const pagesCount = Math.ceil(slidesCount / slidesPerPage);
|
||||||
|
const currentPage = Math.floor(activeSlide / slidesPerPage);
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<nav part="pagination" role="tablist" class="carousel__pagination" aria-controls="scroll-container">
|
||||||
|
${map(range(pagesCount), index => {
|
||||||
|
const isActive = index === currentPage;
|
||||||
|
return html`
|
||||||
|
<span role="presentation">
|
||||||
|
<button
|
||||||
|
@click="${() => this.goToSlide(index * slidesPerPage)}"
|
||||||
|
aria-selected="${isActive ? 'true' : 'false'}"
|
||||||
|
aria-label="${this.localize.term('goToCarouselSlide', index + 1, pagesCount)}"
|
||||||
|
role="tab"
|
||||||
|
part="
|
||||||
|
pagination-item
|
||||||
|
${isActive ? 'pagination-item--active' : ''}
|
||||||
|
"
|
||||||
|
class="${classMap({
|
||||||
|
'carousel__pagination-item': true,
|
||||||
|
'carousel__pagination-item--active': isActive
|
||||||
|
})}"
|
||||||
|
></button>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
private renderNavigation = () => {
|
||||||
|
const { loop, activeSlide } = this;
|
||||||
|
const slides = this.getSlides();
|
||||||
|
const slidesCount = slides.length;
|
||||||
|
|
||||||
|
const prevEnabled = loop || activeSlide > 0;
|
||||||
|
const nextEnabled = loop || activeSlide < slidesCount - 1;
|
||||||
|
const isLtr = this.localize.dir() === 'ltr';
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<nav part="navigation" class="carousel__navigation">
|
||||||
|
<button
|
||||||
|
@click="${prevEnabled ? () => this.previous() : null}"
|
||||||
|
aria-disabled="${prevEnabled ? 'false' : 'true'}"
|
||||||
|
aria-controls="scroll-container"
|
||||||
|
class="${classMap({
|
||||||
|
'carousel__navigation-button': true,
|
||||||
|
'carousel__navigation-button--previous': true,
|
||||||
|
'carousel__navigation-button--disabled': !prevEnabled
|
||||||
|
})}"
|
||||||
|
aria-label="${this.localize.term('goToCarouselPreviousSlide')}"
|
||||||
|
part="navigation-button navigation-button--previous"
|
||||||
|
>
|
||||||
|
<slot name="previous-icon">
|
||||||
|
<sl-icon library="system" name="${isLtr ? 'chevron-left' : 'chevron-right'}"></sl-icon>
|
||||||
|
</slot>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="${nextEnabled ? () => this.next() : null}"
|
||||||
|
aria-disabled="${nextEnabled ? 'false' : 'true'}"
|
||||||
|
aria-controls="scroll-container"
|
||||||
|
class="${classMap({
|
||||||
|
'carousel__navigation-button': true,
|
||||||
|
'carousel__navigation-button--next': true,
|
||||||
|
'carousel__navigation-button--disabled': !nextEnabled
|
||||||
|
})}"
|
||||||
|
aria-label="${this.localize.term('goToCarouselNextSlide')}"
|
||||||
|
part="navigation-button navigation-button--next"
|
||||||
|
>
|
||||||
|
<slot name="next-icon">
|
||||||
|
<sl-icon library="system" name="${isLtr ? 'chevron-right' : 'chevron-left'}"></sl-icon>
|
||||||
|
</slot>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { autoplayController, scrollController } = this;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div part="base" class="carousel">
|
||||||
|
<div
|
||||||
|
id="scroll-container"
|
||||||
|
part="scroll-container"
|
||||||
|
class="${classMap({
|
||||||
|
carousel__slides: true,
|
||||||
|
'carousel__slides--horizontal': this.orientation === 'horizontal',
|
||||||
|
'carousel__slides--vertical': this.orientation === 'vertical'
|
||||||
|
})}"
|
||||||
|
@scrollend="${this.handleScrollEnd}"
|
||||||
|
role="list"
|
||||||
|
tabindex="0"
|
||||||
|
style="${styleMap({
|
||||||
|
'--slides-per-page': String(this.slidesPerPage)
|
||||||
|
})}"
|
||||||
|
aria-live="${!autoplayController.stopped && !autoplayController.paused ? 'off' : 'polite'}"
|
||||||
|
aria-busy="${scrollController.scrolling ? 'true' : 'false'}"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${when(this.navigation, this.renderNavigation)} ${when(this.pagination, this.renderPagination)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'sl-carousel': SlCarousel;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { prefersReducedMotion } from 'src/internal/animate';
|
||||||
|
import { debounce } from 'src/internal/debounce';
|
||||||
|
import { waitForEvent } from 'src/internal/event';
|
||||||
|
import type { ReactiveController, ReactiveElement } from 'lit';
|
||||||
|
|
||||||
|
interface ScrollHost extends ReactiveElement {
|
||||||
|
scrollContainer: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A controller for handling scrolling and mouse dragging.
|
||||||
|
*/
|
||||||
|
export class ScrollController<T extends ScrollHost> implements ReactiveController {
|
||||||
|
private host: T;
|
||||||
|
private pointers = new Set();
|
||||||
|
|
||||||
|
dragging = false;
|
||||||
|
scrolling = false;
|
||||||
|
mouseDragging = false;
|
||||||
|
|
||||||
|
constructor(host: T) {
|
||||||
|
this.host = host;
|
||||||
|
|
||||||
|
host.addController(this);
|
||||||
|
|
||||||
|
this.handleScroll = this.handleScroll.bind(this);
|
||||||
|
this.handlePointerDown = this.handlePointerDown.bind(this);
|
||||||
|
this.handlePointerMove = this.handlePointerMove.bind(this);
|
||||||
|
this.handlePointerUp = this.handlePointerUp.bind(this);
|
||||||
|
this.handlePointerUp = this.handlePointerUp.bind(this);
|
||||||
|
this.handleTouchStart = this.handleTouchStart.bind(this);
|
||||||
|
this.handleTouchEnd = this.handleTouchEnd.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
async hostConnected() {
|
||||||
|
const host = this.host;
|
||||||
|
await host.updateComplete;
|
||||||
|
|
||||||
|
const scrollContainer = host.scrollContainer;
|
||||||
|
|
||||||
|
scrollContainer.addEventListener('scroll', this.handleScroll, { passive: true });
|
||||||
|
scrollContainer.addEventListener('pointerdown', this.handlePointerDown);
|
||||||
|
scrollContainer.addEventListener('pointerup', this.handlePointerUp);
|
||||||
|
scrollContainer.addEventListener('pointercancel', this.handlePointerUp);
|
||||||
|
scrollContainer.addEventListener('touchstart', this.handleTouchStart, { passive: true });
|
||||||
|
scrollContainer.addEventListener('touchend', this.handleTouchEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
hostDisconnected(): void {
|
||||||
|
const host = this.host;
|
||||||
|
const scrollContainer = host.scrollContainer;
|
||||||
|
|
||||||
|
scrollContainer.removeEventListener('scroll', this.handleScroll);
|
||||||
|
scrollContainer.removeEventListener('pointerdown', this.handlePointerDown);
|
||||||
|
scrollContainer.removeEventListener('pointerup', this.handlePointerUp);
|
||||||
|
scrollContainer.removeEventListener('pointercancel', this.handlePointerUp);
|
||||||
|
scrollContainer.removeEventListener('touchstart', this.handleTouchStart);
|
||||||
|
scrollContainer.removeEventListener('touchend', this.handleTouchEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleScroll() {
|
||||||
|
if (!this.scrolling) {
|
||||||
|
this.scrolling = true;
|
||||||
|
this.host.requestUpdate();
|
||||||
|
}
|
||||||
|
this.handleScrollEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
@debounce(100)
|
||||||
|
handleScrollEnd() {
|
||||||
|
if (!this.pointers.size) {
|
||||||
|
this.scrolling = false;
|
||||||
|
this.host.scrollContainer.dispatchEvent(
|
||||||
|
new CustomEvent('scrollend', {
|
||||||
|
bubbles: false,
|
||||||
|
cancelable: false
|
||||||
|
})
|
||||||
|
);
|
||||||
|
this.host.requestUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePointerDown(event: PointerEvent) {
|
||||||
|
if (event.pointerType === 'touch') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollContainer = this.host.scrollContainer;
|
||||||
|
this.pointers.add(event.pointerId);
|
||||||
|
scrollContainer.setPointerCapture(event.pointerId);
|
||||||
|
|
||||||
|
if (this.mouseDragging && this.pointers.size === 1) {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollContainer.addEventListener('pointermove', this.handlePointerMove);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePointerMove(event: PointerEvent) {
|
||||||
|
const host = this.host;
|
||||||
|
const scrollContainer = host.scrollContainer;
|
||||||
|
|
||||||
|
if (scrollContainer.hasPointerCapture(event.pointerId)) {
|
||||||
|
if (!this.dragging) {
|
||||||
|
this.handleDragStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleDrag(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePointerUp(event: PointerEvent) {
|
||||||
|
const host = this.host;
|
||||||
|
const scrollContainer = host.scrollContainer;
|
||||||
|
|
||||||
|
this.pointers.delete(event.pointerId);
|
||||||
|
scrollContainer.releasePointerCapture(event.pointerId);
|
||||||
|
|
||||||
|
if (this.pointers.size === 0) {
|
||||||
|
this.handleDragEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTouchEnd(event: TouchEvent) {
|
||||||
|
for (const touch of event.changedTouches) {
|
||||||
|
this.pointers.delete(touch.identifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTouchStart(event: TouchEvent) {
|
||||||
|
for (const touch of event.touches) {
|
||||||
|
this.pointers.add(touch.identifier);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDragStart() {
|
||||||
|
const host = this.host;
|
||||||
|
|
||||||
|
this.dragging = true;
|
||||||
|
host.scrollContainer.style.setProperty('scroll-snap-type', 'unset');
|
||||||
|
host.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDrag(event: PointerEvent) {
|
||||||
|
this.host.scrollContainer.scrollBy({
|
||||||
|
left: -event.movementX,
|
||||||
|
top: -event.movementY
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDragEnd() {
|
||||||
|
const host = this.host;
|
||||||
|
const scrollContainer = host.scrollContainer;
|
||||||
|
|
||||||
|
scrollContainer.removeEventListener('pointermove', this.handlePointerMove);
|
||||||
|
this.dragging = false;
|
||||||
|
|
||||||
|
const startLeft = scrollContainer.scrollLeft;
|
||||||
|
const startTop = scrollContainer.scrollTop;
|
||||||
|
|
||||||
|
scrollContainer.style.removeProperty('scroll-snap-type');
|
||||||
|
const finalLeft = scrollContainer.scrollLeft;
|
||||||
|
const finalTop = scrollContainer.scrollTop;
|
||||||
|
|
||||||
|
scrollContainer.style.setProperty('scroll-snap-type', 'unset');
|
||||||
|
scrollContainer.scrollTo({ left: startLeft, top: startTop, behavior: 'auto' });
|
||||||
|
scrollContainer.scrollTo({ left: finalLeft, top: finalTop, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
|
||||||
|
|
||||||
|
if (this.scrolling) {
|
||||||
|
await waitForEvent(scrollContainer, 'scrollend');
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollContainer.style.removeProperty('scroll-snap-type');
|
||||||
|
|
||||||
|
host.requestUpdate();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
// @debounce decorator
|
||||||
|
//
|
||||||
|
// Delays the execution until the provided delay in milliseconds has
|
||||||
|
// passed since the last time the function has been called.
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// @debounce(1000)
|
||||||
|
// handleInput() {
|
||||||
|
// ...
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
|
||||||
|
// Each class instance will need to store its timer id, so this unique symbol will be used as property key.
|
||||||
|
const TIMERID_KEY = Symbol();
|
||||||
|
|
||||||
|
export const debounce = (delay: number) => {
|
||||||
|
return <T>(_target: T, _propertyKey: string, descriptor: PropertyDescriptor) => {
|
||||||
|
const fn = descriptor.value as (this: T & { [TIMERID_KEY]: number }, ...args: unknown[]) => unknown;
|
||||||
|
|
||||||
|
descriptor.value = function (this: ThisParameterType<typeof fn>, ...args: Parameters<typeof fn>) {
|
||||||
|
clearTimeout(this[TIMERID_KEY]);
|
||||||
|
|
||||||
|
this[TIMERID_KEY] = window.setTimeout(() => {
|
||||||
|
fn.apply(this, args);
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
|
@ -9,6 +9,8 @@ export { default as SlBreadcrumbItem } from './components/breadcrumb-item/breadc
|
||||||
export { default as SlButton } from './components/button/button';
|
export { default as SlButton } from './components/button/button';
|
||||||
export { default as SlButtonGroup } from './components/button-group/button-group';
|
export { default as SlButtonGroup } from './components/button-group/button-group';
|
||||||
export { default as SlCard } from './components/card/card';
|
export { default as SlCard } from './components/card/card';
|
||||||
|
export { default as SlCarousel } from './components/carousel/carousel';
|
||||||
|
export { default as SlCarouselItem } from './components/carousel-item/carousel-item';
|
||||||
export { default as SlCheckbox } from './components/checkbox/checkbox';
|
export { default as SlCheckbox } from './components/checkbox/checkbox';
|
||||||
export { default as SlColorPicker } from './components/color-picker/color-picker';
|
export { default as SlColorPicker } from './components/color-picker/color-picker';
|
||||||
export { default as SlDetails } from './components/details/details';
|
export { default as SlDetails } from './components/details/details';
|
||||||
|
|
|
@ -24,7 +24,10 @@ const translation: Translation = {
|
||||||
scrollToStart: 'Scroll to start',
|
scrollToStart: 'Scroll to start',
|
||||||
selectAColorFromTheScreen: 'Select a colour from the screen',
|
selectAColorFromTheScreen: 'Select a colour from the screen',
|
||||||
showPassword: 'Show password',
|
showPassword: 'Show password',
|
||||||
toggleColorFormat: 'Toggle colour format'
|
toggleColorFormat: 'Toggle colour format',
|
||||||
|
goToCarouselNextSlide: 'Go to next slide in carousel',
|
||||||
|
goToCarouselPreviousSlide: 'Go to previous slide in carousel',
|
||||||
|
goToCarouselSlide: (slide, count) => `Go to slide ${slide} of ${count} in carousel`
|
||||||
};
|
};
|
||||||
|
|
||||||
registerTranslation(translation);
|
registerTranslation(translation);
|
||||||
|
|
|
@ -24,7 +24,10 @@ const translation: Translation = {
|
||||||
scrollToStart: 'Scroll to start',
|
scrollToStart: 'Scroll to start',
|
||||||
selectAColorFromTheScreen: 'Select a color from the screen',
|
selectAColorFromTheScreen: 'Select a color from the screen',
|
||||||
showPassword: 'Show password',
|
showPassword: 'Show password',
|
||||||
toggleColorFormat: 'Toggle color format'
|
toggleColorFormat: 'Toggle color format',
|
||||||
|
goToCarouselNextSlide: 'Go to next slide in carousel',
|
||||||
|
goToCarouselPreviousSlide: 'Go to previous slide in carousel',
|
||||||
|
goToCarouselSlide: (slide, count) => `Go to slide ${slide} of ${count} in carousel`
|
||||||
};
|
};
|
||||||
|
|
||||||
registerTranslation(translation);
|
registerTranslation(translation);
|
||||||
|
|
|
@ -28,4 +28,9 @@ export interface Translation extends DefaultTranslation {
|
||||||
selectAColorFromTheScreen: string;
|
selectAColorFromTheScreen: string;
|
||||||
showPassword: string;
|
showPassword: string;
|
||||||
toggleColorFormat: string;
|
toggleColorFormat: string;
|
||||||
|
|
||||||
|
// TODO: upate translations for all languages
|
||||||
|
goToCarouselNextSlide?: string;
|
||||||
|
goToCarouselPreviousSlide?: string;
|
||||||
|
goToCarouselSlide?: (slide: number, count: number) => string;
|
||||||
}
|
}
|
||||||
|
|
Ładowanie…
Reference in New Issue