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 tests
pull/851/head
Alessandro 2022-08-06 08:45:45 +00:00 zatwierdzone przez alenaksu
rodzic e632b51eb8
commit c6a6a77bbd
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 581A162165058659
22 zmienionych plików z 2195 dodań i 3 usunięć

1
.gitignore vendored
Wyświetl plik

@ -3,6 +3,5 @@
docs/dist
docs/search.json
dist
examples
node_modules
src/react

Wyświetl plik

@ -31,6 +31,8 @@
- [Button](/components/button)
- [Button Group](/components/button-group)
- [Card](/components/card)
- [Carousel](/components/carousel)
- [Carousel Item](/components/carousel-item)
- [Checkbox](/components/checkbox)
- [Color Picker](/components/color-picker)
- [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

Wyświetl plik

@ -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]

Wyświetl plik

@ -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]

Wyświetl plik

@ -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;
}
`;

Wyświetl plik

@ -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();
});
});

Wyświetl plik

@ -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;
}
}

Wyświetl plik

@ -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();
};
}

Wyświetl plik

@ -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);
}
`;

Wyświetl plik

@ -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');
});
});
});
});
});

Wyświetl plik

@ -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;
}
}

Wyświetl plik

@ -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();
}
}

Wyświetl plik

@ -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);
};
};
};

Wyświetl plik

@ -9,6 +9,8 @@ export { default as SlBreadcrumbItem } from './components/breadcrumb-item/breadc
export { default as SlButton } from './components/button/button';
export { default as SlButtonGroup } from './components/button-group/button-group';
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 SlColorPicker } from './components/color-picker/color-picker';
export { default as SlDetails } from './components/details/details';

Wyświetl plik

@ -24,7 +24,10 @@ const translation: Translation = {
scrollToStart: 'Scroll to start',
selectAColorFromTheScreen: 'Select a colour from the screen',
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);

Wyświetl plik

@ -24,7 +24,10 @@ const translation: Translation = {
scrollToStart: 'Scroll to start',
selectAColorFromTheScreen: 'Select a color from the screen',
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);

Wyświetl plik

@ -28,4 +28,9 @@ export interface Translation extends DefaultTranslation {
selectAColorFromTheScreen: string;
showPassword: string;
toggleColorFormat: string;
// TODO: upate translations for all languages
goToCarouselNextSlide?: string;
goToCarouselPreviousSlide?: string;
goToCarouselSlide?: (slide: number, count: number) => string;
}