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 + + The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash + +``` + +```jsx react +import { SlCarouselItem } from '@shoelace-style/shoelace/dist/react'; + +const App = () => ( + + The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash + +); +``` + +?> 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 + + + The sun shines on the mountains and trees - Photo by Adam Kool on Unsplash + + + A waterfall in the middle of a forest - Photo by Thomas Kelly on Unsplash + + + The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash + + + A field of grass with the sun setting in the background - Photo by Sapan Patel on Unsplash + + + A scenic view of a mountain with clouds rolling in - Photo by V2osk on Unsplash + + + + + + + +``` + +## 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 + + + + + +``` + +### 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 + + + The sun shines on the mountains and trees - Photo by Adam Kool on Unsplash + + + A waterfall in the middle of a forest - Photo by Thomas Kelly on Unsplash + + + The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash + + + A field of grass with the sun setting in the background - Photo by Sapan Patel on Unsplash + + + A scenic view of a mountain with clouds rolling in - Photo by V2osk on Unsplash + + + +``` + +### 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 + + + The sun shines on the mountains and trees - Photo by Adam Kool on Unsplash + + + A waterfall in the middle of a forest - Photo by Thomas Kelly on Unsplash + + + The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash + + + A field of grass with the sun setting in the background - Photo by Sapan Patel on Unsplash + + + A scenic view of a mountain with clouds rolling in - Photo by V2osk on Unsplash + + + + + + 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 + + + The sun shines on the mountains and trees - Photo by Adam Kool on Unsplash + + + A waterfall in the middle of a forest - Photo by Thomas Kelly on Unsplash + + + The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash + + + A field of grass with the sun setting in the background - Photo by Sapan Patel on Unsplash + + + A scenic view of a mountain with clouds rolling in - Photo by V2osk on Unsplash + + + + + + + +``` + +### Custom layout + +The appereance of the carousel can be easly customized through its slots or `part` attributes. + +```html preview + + + The sun shines on the mountains and trees - Photo by Adam Kool on Unsplash + + + A waterfall in the middle of a forest - Photo by Thomas Kelly on Unsplash + + + The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash + + + A field of grass with the sun setting in the background - Photo by Sapan Patel on Unsplash + + + A scenic view of a mountain with clouds rolling in - Photo by V2osk on Unsplash + + + + + + + +``` + +### 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 + + + The sun shines on the mountains and trees - Photo by Adam Kool on Unsplash + + + A waterfall in the middle of a forest - Photo by Thomas Kelly on Unsplash + + + The sun is setting over a lavender field - Photo by Leonard Cotte on Unsplash + + + A field of grass with the sun setting in the background - Photo by Sapan Patel on Unsplash + + + A scenic view of a mountain with clouds rolling in - Photo by V2osk on Unsplash + + +
+
+ Thumbnail Photo by 1 + Thumbnail Photo by 2 + Thumbnail Photo by 3 + Thumbnail Photo by 4 + Thumbnail Photo by 5 +
+
+ + +``` + +[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` + + `; + }; + + render() { + const { autoplayController, scrollController } = this; + + return html` + + `; + } +} + +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; }