diff --git a/.gitignore b/.gitignore
index 6db0b64a..b6f26d43 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,5 @@
docs/dist
docs/search.json
dist
-examples
node_modules
src/react
diff --git a/docs/_sidebar.md b/docs/_sidebar.md
index 32cfbfb2..c45d6639 100644
--- a/docs/_sidebar.md
+++ b/docs/_sidebar.md
@@ -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)
diff --git a/docs/assets/examples/carousel/adam-kool-ndN00KmbJ1c-unsplash.jpg b/docs/assets/examples/carousel/adam-kool-ndN00KmbJ1c-unsplash.jpg
new file mode 100644
index 00000000..17a7ea3c
Binary files /dev/null and b/docs/assets/examples/carousel/adam-kool-ndN00KmbJ1c-unsplash.jpg differ
diff --git a/docs/assets/examples/carousel/leonard-cotte-c1Jp-fo53U8-unsplash.jpg b/docs/assets/examples/carousel/leonard-cotte-c1Jp-fo53U8-unsplash.jpg
new file mode 100644
index 00000000..811b0df2
Binary files /dev/null and b/docs/assets/examples/carousel/leonard-cotte-c1Jp-fo53U8-unsplash.jpg differ
diff --git a/docs/assets/examples/carousel/sapan-patel-i9Q9bc-WgfE-unsplash.jpg b/docs/assets/examples/carousel/sapan-patel-i9Q9bc-WgfE-unsplash.jpg
new file mode 100644
index 00000000..a742b196
Binary files /dev/null and b/docs/assets/examples/carousel/sapan-patel-i9Q9bc-WgfE-unsplash.jpg differ
diff --git a/docs/assets/examples/carousel/thomas-kelley-JoH60FhTp50-unsplash.jpg b/docs/assets/examples/carousel/thomas-kelley-JoH60FhTp50-unsplash.jpg
new file mode 100644
index 00000000..b8fa264c
Binary files /dev/null and b/docs/assets/examples/carousel/thomas-kelley-JoH60FhTp50-unsplash.jpg differ
diff --git a/docs/assets/examples/carousel/v2osk-1Z2niiBPg5A-unsplash.jpg b/docs/assets/examples/carousel/v2osk-1Z2niiBPg5A-unsplash.jpg
new file mode 100644
index 00000000..bd575fbf
Binary files /dev/null and b/docs/assets/examples/carousel/v2osk-1Z2niiBPg5A-unsplash.jpg differ
diff --git a/docs/components/carousel-item.md b/docs/components/carousel-item.md
new file mode 100644
index 00000000..7f22c534
--- /dev/null
+++ b/docs/components/carousel-item.md
@@ -0,0 +1,29 @@
+# Carousel Item
+
+[component-header:sl-carousel-item]
+
+```html preview
+
+
+
+```
+
+```jsx react
+import { SlCarouselItem } from '@shoelace-style/shoelace/dist/react';
+
+const App = () => (
+
+
+
+);
+```
+
+?> Additional demonstrations can be found in the [carousel examples](/components/carousel).
+
+[component-metadata:sl-carousel-item]
diff --git a/docs/components/carousel.md b/docs/components/carousel.md
new file mode 100644
index 00000000..d8a2c2f5
--- /dev/null
+++ b/docs/components/carousel.md
@@ -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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loop
+ Show navigation
+ Show pagination
+ Autoplay (3s)
+ Mouse dragging
+
+
+
+
+
+ Horizontal
+ Vertical
+
+
+
+
+
+
+```
+
+## 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
+
+ Slide 1
+ Slide 2
+ Slide 3
+ Slide 4
+ Slide 5
+ Slide 6
+
+```
+
+### 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
+
+ Slide 1
+ Slide 2
+ Slide 3
+
+
+
+ Add slide
+ Remove slide
+
+
+
+```
+
+### 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1 / 1
+ 3 / 2
+ 16 / 9
+
+
+
+```
+
+### 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### Custom layout
+
+The appereance of the carousel can be easly customized through its slots or `part` attributes.
+
+```html preview
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+### 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+[component-metadata:sl-carousel]
diff --git a/src/components/carousel-item/carousel-item.styles.ts b/src/components/carousel-item/carousel-item.styles.ts
new file mode 100644
index 00000000..c5014a0f
--- /dev/null
+++ b/src/components/carousel-item/carousel-item.styles.ts
@@ -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;
+ }
+`;
diff --git a/src/components/carousel-item/carousel-item.test.ts b/src/components/carousel-item/carousel-item.test.ts
new file mode 100644
index 00000000..3cf84981
--- /dev/null
+++ b/src/components/carousel-item/carousel-item.test.ts
@@ -0,0 +1,17 @@
+import { expect, fixture, html } from '@open-wc/testing';
+
+describe('', () => {
+ it('should render a component', async () => {
+ const el = await fixture(html` `);
+
+ expect(el).to.exist;
+ });
+
+ it('should pass accessibility tests', async () => {
+ // Arrange
+ const el = await fixture(html`
`);
+
+ // Assert
+ await expect(el).to.be.accessible();
+ });
+});
diff --git a/src/components/carousel-item/carousel-item.ts b/src/components/carousel-item/carousel-item.ts
new file mode 100644
index 00000000..e46869ab
--- /dev/null
+++ b/src/components/carousel-item/carousel-item.ts
@@ -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` `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'sl-carousel-item': SlCarouselItem;
+ }
+}
diff --git a/src/components/carousel/autoplay-controller.ts b/src/components/carousel/autoplay-controller.ts
new file mode 100644
index 00000000..4cd97879
--- /dev/null
+++ b/src/components/carousel/autoplay-controller.ts
@@ -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();
+ };
+}
diff --git a/src/components/carousel/carousel.styles.ts b/src/components/carousel/carousel.styles.ts
new file mode 100644
index 00000000..ed2b4516
--- /dev/null
+++ b/src/components/carousel/carousel.styles.ts
@@ -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);
+ }
+`;
diff --git a/src/components/carousel/carousel.test.ts b/src/components/carousel/carousel.test.ts
new file mode 100644
index 00000000..a78a754a
--- /dev/null
+++ b/src/components/carousel/carousel.test.ts
@@ -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('', () => {
+ it('should render a carousel with default configuration', async () => {
+ // Arrange
+ const el = await fixture(html`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+
+ // 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(html`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+ 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(html`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+ 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(html`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+
+ // 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(html`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+
+ // 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`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+
+ // 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(html`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+ 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`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+
+ // 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(html`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+
+ // 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(html`
+
+ Node 1
+ Node 2
+ Node 3
+ Node 4
+
+ `);
+
+ // 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(html`
+
+ Node 1
+ Node 2
+
+ `);
+
+ // 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(html`
+
+ Node 1
+ Node 2
+
+ `);
+
+ // 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(html`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+ 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(html`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+ 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(html`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+ 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(html`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+
+ // 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(html`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+
+ 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(html`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+
+ 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(html`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+ 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(html`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+ 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(html`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+ 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(html`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+ 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(html`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+
+ 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(html`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+
+ 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(html`
+
+ Node 1
+ Node 2
+ Node 3
+
+ `);
+
+ await el.updateComplete;
+
+ // Act
+ el.dispatchEvent(new Event('focusin'));
+ await el.updateComplete;
+
+ // Assert
+ expect(el.scrollContainer).to.have.attribute('aria-live', 'polite');
+ });
+ });
+ });
+ });
+});
diff --git a/src/components/carousel/carousel.ts b/src/components/carousel/carousel.ts
new file mode 100644
index 00000000..1b8563f8
--- /dev/null
+++ b/src/components/carousel/carousel.ts
@@ -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 ``.
+ * @slot previous-icon - Optional previous icon to use instead of the default. Works best with ``.
+ *
+ * @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();
+
+ 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`
+
+ `;
+ };
+
+ 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`
+
+ 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"
+ >
+
+
+
+
+
+ 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"
+ >
+
+
+
+
+
+ `;
+ };
+
+ render() {
+ const { autoplayController, scrollController } = this;
+
+ return html`
+
+
+
+
+
+ ${when(this.navigation, this.renderNavigation)} ${when(this.pagination, this.renderPagination)}
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'sl-carousel': SlCarousel;
+ }
+}
diff --git a/src/components/carousel/scroll-controller.ts b/src/components/carousel/scroll-controller.ts
new file mode 100644
index 00000000..adb4468a
--- /dev/null
+++ b/src/components/carousel/scroll-controller.ts
@@ -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 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();
+ }
+}
diff --git a/src/internal/debounce.ts b/src/internal/debounce.ts
new file mode 100644
index 00000000..72f618a9
--- /dev/null
+++ b/src/internal/debounce.ts
@@ -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 (_target: T, _propertyKey: string, descriptor: PropertyDescriptor) => {
+ const fn = descriptor.value as (this: T & { [TIMERID_KEY]: number }, ...args: unknown[]) => unknown;
+
+ descriptor.value = function (this: ThisParameterType, ...args: Parameters) {
+ clearTimeout(this[TIMERID_KEY]);
+
+ this[TIMERID_KEY] = window.setTimeout(() => {
+ fn.apply(this, args);
+ }, delay);
+ };
+ };
+};
diff --git a/src/shoelace.ts b/src/shoelace.ts
index a325a42c..0b20a5a2 100644
--- a/src/shoelace.ts
+++ b/src/shoelace.ts
@@ -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';
diff --git a/src/translations/en-gb.ts b/src/translations/en-gb.ts
index 0202cf4b..5dff2e58 100644
--- a/src/translations/en-gb.ts
+++ b/src/translations/en-gb.ts
@@ -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);
diff --git a/src/translations/en.ts b/src/translations/en.ts
index 2938b408..19d2afc5 100644
--- a/src/translations/en.ts
+++ b/src/translations/en.ts
@@ -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);
diff --git a/src/utilities/localize.ts b/src/utilities/localize.ts
index 88662b5d..de94e781 100644
--- a/src/utilities/localize.ts
+++ b/src/utilities/localize.ts
@@ -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;
}