Skip to content

Commit

Permalink
[POC] feat: Carousel component
Browse files Browse the repository at this point in the history
  • Loading branch information
wessamzaghloul committed Apr 10, 2024
1 parent efcd4bf commit 7407d27
Show file tree
Hide file tree
Showing 7 changed files with 454 additions and 0 deletions.
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

0 comments on commit 7407d27

Please sign in to comment.