Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[POC] feat: Carousel component #2249

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/components/carousel/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Carousel ||10

-> go to Overview
39 changes: 39 additions & 0 deletions docs/components/carousel/overview.md
Original file line number Diff line number Diff line change
@@ -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`
<lion-carousel>
<img slot="slide" src="https://picsum.photos/800/400?random=1" alt="random image for demo" />
<img slot="slide" src="https://picsum.photos/800/400?random=2" alt="random image for demo" />
<img slot="slide" src="https://picsum.photos/800/400?random=3" alt="random image for demo" />
<img slot="slide" src="https://picsum.photos/800/400?random=4" alt="random image for demo" />
<!-- Insert more elements as needed -->
</lion-carousel>
`;
```

## 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';
```
59 changes: 59 additions & 0 deletions docs/components/carousel/use-cases.md
Original file line number Diff line number Diff line change
@@ -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
<lion-carousel current="2">
<div slot="slide">slide 1</div>
<div slot="slide">slide 2</div>
<div slot="slide">slide 3</div>
<div slot="slide">Any HTML content</div>
<p slot="slide">More content here</p>
</lion-carousel>
```

## 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
<lion-carousel slideshow duration="4000">
<div slot="slide">slide 1</div>
<div slot="slide">slide 2</div>
<div slot="slide">slide 3</div>
<div slot="slide">Any HTML content</div>
<p slot="slide">More content here</p>
</lion-carousel>
```

## Carousel with Pagination

You can compose the carousel component to work with LionPagination component

```html preview-story
<lion-carousel pagination>
<div slot="slide">slide 1</div>
<div slot="slide">slide 2</div>
<div slot="slide">slide 3</div>
<div slot="slide">Any HTML content</div>
<p slot="slide">More content here</p>
</lion-carousel>
```

## Carousel with all options

```html preview-story
<lion-carousel pagination slideshow>
<img slot="slide" src="https://picsum.photos/800/400?random=1" alt="random image for demo" />
<img slot="slide" src="https://picsum.photos/800/400?random=2" alt="random image for demo" />
<img slot="slide" src="https://picsum.photos/800/400?random=3" alt="random image for demo" />
<img slot="slide" src="https://picsum.photos/800/400?random=4" alt="random image for demo" />
</lion-carousel>
```
240 changes: 240 additions & 0 deletions packages/ui/components/carousel/src/LionCarousel.js
Original file line number Diff line number Diff line change
@@ -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`
<div @keydown="${this._handleKeyDown}">
<slot name="slide"></slot>
</div>
<button class="prev" @click="${this.prevSlide}" aria-label="previous">&#9664;</button>
<button class="next" @click="${this.nextSlide}" aria-label="next">&#9654;</button>
${this._paginationTemplate} ${this._slideshowControlsTemplate}
<div aria-live="polite" hidden>
Viewing slide ${this.currentIndex} of ${this.slides.length}
</div>
`;
}

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`
<lion-pagination
count="${this._getSlidesCount()}"
current="${this.currentIndex}"
@current-changed=${this._handlePaginationChange}
></lion-pagination>
`
: '';
}

/**
* @protected
*/
get _slideshowControlsTemplate() {
return this.slideshow
? html`
<button @click="${this._startSlideShow}" aria-label="Start slide show">▶</button>
<button @click="${this._stopSlideShow}" aria-label="Stop slide show">◼</button>
`
: '';
}

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