diff --git a/docs/components/carousel/index.md b/docs/components/carousel/index.md new file mode 100644 index 0000000000..ba95201aa9 --- /dev/null +++ b/docs/components/carousel/index.md @@ -0,0 +1,3 @@ +# Carousel ||10 + +-> go to Overview diff --git a/docs/components/carousel/overview.md b/docs/components/carousel/overview.md new file mode 100644 index 0000000000..c80859af4c --- /dev/null +++ b/docs/components/carousel/overview.md @@ -0,0 +1,39 @@ +# Carousel >> Overview ||10 + +A carousel web component. The component allows users to navigate through a series of images using either UI controls or keyboard navigation. + +```js script +import { html } from '@mdjs/mdjs-preview'; +import '@lion/ui/define/lion-carousel.js'; +``` + +```js preview-story +export const main = () => + html` + + random image for demo + random image for demo + random image for demo + random image for demo + + + `; +``` + +## Features + +- Flexible Content and Styling: LionCarousel accepts diverse content through `slot=slide` and offers styling options. +- Autoplay and Manual Navigation: It supports both automatic cycling of slides for showcases and manual navigation for user control. +- Accessibility-Focused: Built with accessibility in mind, featuring keyboard navigation, ARIA attributes, and motion preferences. + +## Installation + +```bash +npm i --save @lion/ui +``` + +```js +import { LionCarousel } from '@lion/ui/carousel.js'; +// or +import '@lion/ui/define/lion-carousel.js'; +``` diff --git a/docs/components/carousel/use-cases.md b/docs/components/carousel/use-cases.md new file mode 100644 index 0000000000..0ee1979796 --- /dev/null +++ b/docs/components/carousel/use-cases.md @@ -0,0 +1,59 @@ +# Carousel >> Use Cases ||20 + +```js script +import { html } from '@mdjs/mdjs-preview'; +import '@lion/ui/define/lion-carousel.js'; +``` + +## Pre-select the first active slide + +You can preselect the active slide using the `current` attribute. + +```html preview-story + +
slide 1
+
slide 2
+
slide 3
+
Any HTML content
+

More content here

+
+``` + +## Autoplay Carousel + +You can define an autoplay carousel using the `slideshow` atribute and set the delay duration using the `duration` attribute, also adds the "play" and "stop" buttons for users to control it. + +```html preview-story + +
slide 1
+
slide 2
+
slide 3
+
Any HTML content
+

More content here

+
+``` + +## Carousel with Pagination + +You can compose the carousel component to work with LionPagination component + +```html preview-story + +
slide 1
+
slide 2
+
slide 3
+
Any HTML content
+

More content here

+
+``` + +## Carousel with all options + +```html preview-story + + random image for demo + random image for demo + random image for demo + random image for demo + +``` diff --git a/packages/ui/components/carousel/src/LionCarousel.js b/packages/ui/components/carousel/src/LionCarousel.js new file mode 100644 index 0000000000..8a0906a6b3 --- /dev/null +++ b/packages/ui/components/carousel/src/LionCarousel.js @@ -0,0 +1,240 @@ +import { ScopedElementsMixin } from '@open-wc/scoped-elements/lit-element.js'; +import { LitElement, html, css } from 'lit'; +import { LionPagination } from '@lion/ui/pagination.js'; + +export class LionCarousel extends ScopedElementsMixin(LitElement) { + static get scopedElements() { + return { + 'lion-pagination': LionPagination, + }; + } + + static get styles() { + return [ + css` + :host { + display: block; + } + ::slotted([slot='slide']) { + display: none; + } + ::slotted([slot='slide'].active) { + display: block; + } + :host [hidden] { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; + } + `, + ]; + } + + static get properties() { + return { + currentIndex: { type: Number, attribute: 'current', reflect: true }, + pagination: { type: Boolean, attribute: 'pagination', reflect: true }, + slideshow: { type: Boolean, reflect: true }, + duration: { type: Number, reflect: true }, + }; + } + + render() { + return html` +
+ +
+ + + ${this._paginationTemplate} ${this._slideshowControlsTemplate} + + `; + } + + constructor() { + super(); + this.currentIndex = 1; + this.pagination = false; + this.slideshow = false; + this.slideshowAnimating = false; + /** + * @type {number | undefined} + */ + this.duration = undefined; + } + + firstUpdated() { + this._updateActiveSlide(); + if (this.slideshow) this._startSlideShow(); + } + + connectedCallback() { + super.connectedCallback(); + this.slides.forEach((slide, index) => { + // eslint-disable-next-line no-param-reassign + slide.ariaLabel = `${index + 1}/${this.slides.length}`; + }); + } + + /** + * @protected + */ + get _paginationTemplate() { + return this.pagination + ? html` + + ` + : ''; + } + + /** + * @protected + */ + get _slideshowControlsTemplate() { + return this.slideshow + ? html` + + + ` + : ''; + } + + /** + * @param {import('lit').PropertyValues } changedProperties + */ + updated(changedProperties) { + super.updated(changedProperties); + if (changedProperties.has('currentIndex')) { + this._updateActiveSlide(); + } + } + + /** + * Animates slide transition. + * This method can be overridden by subclasses to implement custom animation logic. + * @param {Number} _oldIndex index of slide transitioning from. + * @param {Number} _newIndex index of slide transitioning to. + * @param {'next'|'prev'} _direction The direction of the transition ('next' or 'prev'). + */ + // eslint-disable-next-line class-methods-use-this, no-empty-function, no-unused-vars + async _slideAnimation(_oldIndex, _newIndex, _direction) { + // Default implementation does nothing. + } + + async nextSlide() { + const oldIndex = this.currentIndex; + const newIndex = (this.currentIndex % this.slides.length) + 1; + await this._slideAnimation(oldIndex, newIndex, 'next'); + this.currentIndex = newIndex; + } + + async prevSlide() { + const oldIndex = this.currentIndex; + const newIndex = ((this.currentIndex - 2 + this.slides.length) % this.slides.length) + 1; + await this._slideAnimation(oldIndex, newIndex, 'prev'); + this.currentIndex = newIndex; + } + + /** + * + * @param {{ key: string; }} e + */ + _handleKeyDown(e) { + if (e.key === 'ArrowRight') { + this.nextSlide(); + } else if (e.key === 'ArrowLeft') { + this.prevSlide(); + } + } + + /** + * + * @param {{ target: { current: any; }; }} e + */ + async _handlePaginationChange(e) { + const newIndex = e.target.current; + if (newIndex === this.currentIndex) return; + const direction = newIndex > this.currentIndex ? 'next' : 'prev'; + await this._slideAnimation(this.currentIndex, newIndex, direction); + this.currentIndex = newIndex; + } + + get slides() { + return /** @type {HTMLElement[]} */ (Array.from(this.children)).filter( + child => child.slot === 'slide', + ); + } + + /** + * @protected + */ + _updateActiveSlide() { + const { slides } = this; + slides.forEach((slide, index) => { + const isActive = index + 1 === this.currentIndex; + if (isActive) { + slide.classList.add('active'); + slide.removeAttribute('tabindex'); + } else { + slide.classList.remove('active'); + // eslint-disable-next-line no-param-reassign + slide.tabIndex = -1; + } + slide.setAttribute('aria-hidden', `${!isActive}`); + if (isActive && !this.slideshowAnimating) { + // eslint-disable-next-line no-param-reassign + slide.tabIndex = 0; + slide.focus(); + } + }); + } + + _getSlidesCount() { + return this.slides.length; + } + + _updateAriaLiveSettingForAutoplay() { + const liveRegion = this.shadowRoot?.querySelector('.aria-live'); + if (this.slideshowAnimating) { + liveRegion?.setAttribute('aria-live', 'off'); + } else { + liveRegion?.setAttribute('aria-live', 'polite'); + } + } + + /** + * @protected + */ + _startSlideShow() { + if (this.slideshowAnimating) return; + this.slideshowAnimating = true; + const duration = this.duration || 5000; + this.slideShowTimer = setInterval(() => { + this.nextSlide(); + }, duration); + } + + /** + * @protected + */ + _stopSlideShow() { + if (!this.slideshowAnimating) return; + this.slideshowAnimating = false; + clearInterval(this.slideShowTimer); + } + + disconnectedCallback() { + super.disconnectedCallback(); + clearInterval(this.slideShowTimer); + } +} diff --git a/packages/ui/components/carousel/test/lion-carousel.test.js b/packages/ui/components/carousel/test/lion-carousel.test.js new file mode 100644 index 0000000000..5eb0d21671 --- /dev/null +++ b/packages/ui/components/carousel/test/lion-carousel.test.js @@ -0,0 +1,109 @@ +import '@lion/ui/define/lion-carousel.js'; +import { expect, fixture as _fixture, html } from '@open-wc/testing'; + +/** + * @typedef {import('../src/LionCarousel.js').LionCarousel} LionCarousel + */ + +/** + * @typedef {import('lit').TemplateResult} TemplateResult + */ + +const fixture = /** @type {(arg: TemplateResult) => Promise} */ (_fixture); + +describe('lion-carousel', () => { + it('initializes with correct default property values', async () => { + const el = await fixture(html``); + expect(el.currentIndex).to.equal(1); + expect(el.pagination).to.be.false; + expect(el.slideshow).to.be.false; + }); + + it('renders slot content correctly', async () => { + const el = await fixture(html` + +
Slide 1
+
Slide 2
+
+ `); + expect(el.slides.length).to.equal(2); + }); + + it('updates active slide on _nextSlide call', async () => { + const el = await fixture(html` + +
Slide 1
+
Slide 2
+
+ `); + el.nextSlide(); + await el.updateComplete; + expect(el.currentIndex).to.equal(2); + }); + + it('loops to the first slide from the last on _nextSlide call', async () => { + const el = await fixture(html` + +
Slide 1
+
Slide 2
+
+ `); + el.currentIndex = 2; + el.nextSlide(); + await el.updateComplete; + expect(el.currentIndex).to.equal(1); // Loops back to the first slide + }); + + it('should start slideshow when slideShow is passed', async () => { + const el = await fixture(html``); + expect(el.slideshow).to.be.true; + expect(el.slideShowTimer).to.exist; + // @ts-ignore + el._stopSlideShow(); // Clean up to prevent the timer from running + }); +}); + +describe('LionCarousel Accessibility', () => { + it('passes accessibility tests', async () => { + const el = await fixture(html` + +
Slide 1
+
Slide 2
+
+ `); + await expect(el).to.be.accessible(); + }); + + it('passes accessibility tests with pagination enabled', async () => { + const el = await fixture(html` + +
Slide 1
+
Slide 2
+
+ `); + await expect(el).to.be.accessible(); + }); + + it('passes accessibility tests during slideshow', async () => { + const el = await fixture(html` + +
Slide 1
+
Slide 2
+
+ `); + + // Simulate starting the slideshow + // @ts-ignore + el._startSlideShow(); + await el.updateComplete; + + // Wait for the next slide due to animations or transitions + // await aTimeout(100); + + await expect(el).to.be.accessible(); + + // Cleanup to stop the slideshow + // @ts-ignore + el._stopSlideShow(); + }); +}); diff --git a/packages/ui/exports/carousel.js b/packages/ui/exports/carousel.js new file mode 100644 index 0000000000..826c9150ab --- /dev/null +++ b/packages/ui/exports/carousel.js @@ -0,0 +1 @@ +export { LionCarousel } from '../components/carousel/src/LionCarousel.js'; diff --git a/packages/ui/exports/define/lion-carousel.js b/packages/ui/exports/define/lion-carousel.js new file mode 100644 index 0000000000..45a8e53b05 --- /dev/null +++ b/packages/ui/exports/define/lion-carousel.js @@ -0,0 +1,3 @@ +import { LionCarousel } from '../carousel.js'; + +customElements.define('lion-carousel', LionCarousel);