mirror of
https://github.com/shoelace-style/webawesome.git
synced 2026-01-12 04:09:12 +00:00
668 lines
24 KiB
TypeScript
668 lines
24 KiB
TypeScript
import '../../internal/scrollend-polyfill.js';
|
|
|
|
import type { PropertyValueMap } from 'lit';
|
|
import { html, isServer } from 'lit';
|
|
import { customElement, eventOptions, 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 { WaSlideChangeEvent } from '../../events/slide-change.js';
|
|
import { prefersReducedMotion } from '../../internal/animate.js';
|
|
import { waitForEvent } from '../../internal/event.js';
|
|
import { clamp } from '../../internal/math.js';
|
|
import { watch } from '../../internal/watch.js';
|
|
import WebAwesomeElement from '../../internal/webawesome-element.js';
|
|
import { LocalizeController } from '../../utilities/localize.js';
|
|
import type WaCarouselItem from '../carousel-item/carousel-item.js';
|
|
import '../icon/icon.js';
|
|
import { AutoplayController } from './autoplay-controller.js';
|
|
import styles from './carousel.css';
|
|
|
|
/**
|
|
* @summary Carousels display an arbitrary number of content slides along a horizontal or vertical axis.
|
|
*
|
|
* @since 2.2
|
|
* @status experimental
|
|
*
|
|
* @dependency wa-icon
|
|
*
|
|
* @event {{ index: number, slide: WaCarouselItem }} wa-slide-change - Emitted when the active slide changes.
|
|
*
|
|
* @slot - The carousel's main content, one or more `<wa-carousel-item>` elements.
|
|
* @slot next-icon - Optional next icon to use instead of the default. Works best with `<wa-icon>`.
|
|
* @slot previous-icon - Optional previous icon to use instead of the default. Works best with `<wa-icon>`.
|
|
*
|
|
* @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 [--aspect-ratio=16/9] - The aspect ratio of each slide.
|
|
* @cssproperty --navigation-color - The color of the navigation arrows.
|
|
* @cssproperty --pagination-color - The color of the dots indicating the number of slides.
|
|
* @cssproperty --pagination-color-active - The color of the dot indicating the active slide.
|
|
* @cssproperty --scroll-hint - The amount of padding to apply to the scroll area, allowing adjacent slides to become
|
|
* partially visible as a scroll hint.
|
|
* @cssproperty --slide-gap - The space between each slide.
|
|
*/
|
|
@customElement('wa-carousel')
|
|
export default class WaCarousel extends WebAwesomeElement {
|
|
static shadowStyle = styles;
|
|
|
|
/** When set, allows the user to navigate the carousel in the same direction indefinitely. */
|
|
@property({ type: Boolean, reflect: true }) loop = false;
|
|
|
|
@property({ type: Number, reflect: true }) slides = 0;
|
|
@property({ type: Number, reflect: true }) currentSlide = 0;
|
|
|
|
/** 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`
|
|
* greater than one. It can't be higher than `slides-per-page`.
|
|
*/
|
|
@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('.carousel__slides') scrollContainer: HTMLElement;
|
|
@query('.carousel__pagination') paginationContainer: HTMLElement;
|
|
|
|
// The index of the active slide
|
|
@state() activeSlide = 0;
|
|
|
|
@state() scrolling = false;
|
|
|
|
@state() dragging = false;
|
|
|
|
private autoplayController = new AutoplayController(this, () => this.next());
|
|
private dragStartPosition: [number, number] = [-1, -1];
|
|
private readonly localize = new LocalizeController(this);
|
|
private mutationObserver: MutationObserver;
|
|
private pendingSlideChange = false;
|
|
|
|
connectedCallback(): void {
|
|
super.connectedCallback();
|
|
this.setAttribute('role', 'region');
|
|
this.setAttribute('aria-label', this.localize.term('carousel'));
|
|
}
|
|
|
|
disconnectedCallback(): void {
|
|
super.disconnectedCallback();
|
|
this.mutationObserver?.disconnect();
|
|
}
|
|
|
|
protected firstUpdated(): void {
|
|
this.initializeSlides();
|
|
this.mutationObserver = new MutationObserver(this.handleSlotChange);
|
|
this.mutationObserver.observe(this, {
|
|
childList: true,
|
|
subtree: true,
|
|
});
|
|
}
|
|
|
|
protected willUpdate(changedProperties: PropertyValueMap<WaCarousel> | Map<PropertyKey, unknown>): void {
|
|
// Ensure the slidesPerMove is never higher than the slidesPerPage
|
|
if (changedProperties.has('slidesPerMove') || changedProperties.has('slidesPerPage')) {
|
|
this.slidesPerMove = Math.min(this.slidesPerMove, this.slidesPerPage);
|
|
}
|
|
}
|
|
|
|
private getPageCount() {
|
|
const slidesCount = this.getSlides().length;
|
|
const { slidesPerPage, slidesPerMove, loop } = this;
|
|
|
|
const pages = loop ? slidesCount / slidesPerMove : (slidesCount - slidesPerPage) / slidesPerMove + 1;
|
|
|
|
return Math.ceil(pages);
|
|
}
|
|
|
|
private getCurrentPage() {
|
|
return Math.ceil(this.activeSlide / this.slidesPerMove);
|
|
}
|
|
|
|
private canScrollNext(): boolean {
|
|
return this.loop || this.getCurrentPage() < this.getPageCount() - 1;
|
|
}
|
|
|
|
private canScrollPrev(): boolean {
|
|
return this.loop || this.getCurrentPage() > 0;
|
|
}
|
|
|
|
/** @internal Gets all carousel items. */
|
|
private getSlides({ excludeClones = true }: { excludeClones?: boolean } = {}) {
|
|
return [...this.children].filter(
|
|
(el: HTMLElement) => this.isCarouselItem(el) && (!excludeClones || !el.hasAttribute('data-clone')),
|
|
) as WaCarouselItem[];
|
|
}
|
|
|
|
private handleClick(event: MouseEvent) {
|
|
if (this.dragging && this.dragStartPosition[0] > 0 && this.dragStartPosition[1] > 0) {
|
|
const deltaX = Math.abs(this.dragStartPosition[0] - event.clientX);
|
|
const deltaY = Math.abs(this.dragStartPosition[1] - event.clientY);
|
|
const delta = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
|
|
|
// Prevents clicks on interactive elements while dragging if the click is within a small range. This prevents
|
|
// accidental drags from interfering with intentional clicks.
|
|
if (delta >= 10) {
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
}
|
|
|
|
private handleKeyDown(event: KeyboardEvent) {
|
|
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) {
|
|
const target = event.target as HTMLElement;
|
|
const isRtl = this.localize.dir() === 'rtl';
|
|
const isFocusInPagination = target.closest('[part~="pagination-item"]') !== null;
|
|
const isNext =
|
|
event.key === 'ArrowDown' || (!isRtl && event.key === 'ArrowRight') || (isRtl && event.key === 'ArrowLeft');
|
|
const isPrevious =
|
|
event.key === 'ArrowUp' || (!isRtl && event.key === 'ArrowLeft') || (isRtl && event.key === 'ArrowRight');
|
|
|
|
event.preventDefault();
|
|
|
|
if (isPrevious) {
|
|
this.previous();
|
|
}
|
|
|
|
if (isNext) {
|
|
this.next();
|
|
}
|
|
|
|
if (event.key === 'Home') {
|
|
this.goToSlide(0);
|
|
}
|
|
|
|
if (event.key === 'End') {
|
|
this.goToSlide(this.getSlides().length - 1);
|
|
}
|
|
|
|
if (isFocusInPagination) {
|
|
this.updateComplete.then(() => {
|
|
const activePaginationItem = this.shadowRoot?.querySelector<HTMLButtonElement>(
|
|
'[part~="pagination-item--active"]',
|
|
);
|
|
|
|
if (activePaginationItem) {
|
|
activePaginationItem.focus();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
private handleMouseDragStart(event: PointerEvent) {
|
|
const canDrag = this.mouseDragging && event.button === 0;
|
|
if (canDrag) {
|
|
event.preventDefault();
|
|
|
|
document.addEventListener('pointermove', this.handleMouseDrag, { capture: true, passive: true });
|
|
document.addEventListener('pointerup', this.handleMouseDragEnd, { capture: true, once: true });
|
|
}
|
|
}
|
|
|
|
private handleMouseDrag = (event: PointerEvent) => {
|
|
if (!this.dragging) {
|
|
// Start dragging if it hasn't yet
|
|
this.scrollContainer.style.setProperty('scroll-snap-type', 'none');
|
|
this.dragging = true;
|
|
this.dragStartPosition = [event.clientX, event.clientY];
|
|
}
|
|
|
|
this.scrollContainer.scrollBy({
|
|
left: -event.movementX,
|
|
top: -event.movementY,
|
|
behavior: 'instant',
|
|
});
|
|
};
|
|
|
|
private handleMouseDragEnd = () => {
|
|
const scrollContainer = this.scrollContainer;
|
|
|
|
document.removeEventListener('pointermove', this.handleMouseDrag, { capture: true });
|
|
|
|
// get the current scroll position
|
|
const startLeft = scrollContainer.scrollLeft;
|
|
const startTop = scrollContainer.scrollTop;
|
|
|
|
// remove the scroll-snap-type property so that the browser will snap the slide to the correct position
|
|
scrollContainer.style.removeProperty('scroll-snap-type');
|
|
|
|
// fix(safari): forcing a style recalculation doesn't seem to immediately update the scroll
|
|
// position in Safari. Setting "overflow" to "hidden" should force this behavior.
|
|
scrollContainer.style.setProperty('overflow', 'hidden');
|
|
|
|
// get the final scroll position to the slide snapped by the browser
|
|
const finalLeft = scrollContainer.scrollLeft;
|
|
const finalTop = scrollContainer.scrollTop;
|
|
|
|
// restore the scroll position to the original one, so that it can be smoothly animated if needed
|
|
scrollContainer.style.removeProperty('overflow');
|
|
scrollContainer.style.setProperty('scroll-snap-type', 'none');
|
|
scrollContainer.scrollTo({ left: startLeft, top: startTop, behavior: 'instant' });
|
|
|
|
requestAnimationFrame(async () => {
|
|
if (startLeft !== finalLeft || startTop !== finalTop) {
|
|
scrollContainer.scrollTo({
|
|
left: finalLeft,
|
|
top: finalTop,
|
|
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
|
|
});
|
|
await waitForEvent(scrollContainer, 'scrollend');
|
|
}
|
|
|
|
scrollContainer.style.removeProperty('scroll-snap-type');
|
|
|
|
this.dragging = false;
|
|
this.dragStartPosition = [-1, -1];
|
|
this.handleScrollEnd();
|
|
});
|
|
};
|
|
|
|
@eventOptions({ passive: true })
|
|
private handleScroll() {
|
|
this.scrolling = true;
|
|
if (!this.pendingSlideChange) {
|
|
this.synchronizeSlides();
|
|
}
|
|
}
|
|
|
|
/** @internal Synchronizes the slides with the IntersectionObserver API. */
|
|
private synchronizeSlides() {
|
|
const io = new IntersectionObserver(
|
|
entries => {
|
|
io.disconnect();
|
|
|
|
for (const entry of entries) {
|
|
const slide = entry.target;
|
|
slide.toggleAttribute('inert', !entry.isIntersecting);
|
|
slide.classList.toggle('--in-view', entry.isIntersecting);
|
|
slide.setAttribute('aria-hidden', entry.isIntersecting ? 'false' : 'true');
|
|
}
|
|
|
|
const firstIntersecting = entries.find(entry => entry.isIntersecting);
|
|
|
|
if (!firstIntersecting) {
|
|
return;
|
|
}
|
|
|
|
const slidesWithClones = this.getSlides({ excludeClones: false });
|
|
const slidesCount = this.getSlides().length;
|
|
|
|
// Update the current index based on the first visible slide
|
|
const slideIndex = slidesWithClones.indexOf(firstIntersecting.target as WaCarouselItem);
|
|
// Normalize the index to ignore clones
|
|
const normalizedIndex = this.loop ? slideIndex - this.slidesPerPage : slideIndex;
|
|
|
|
if (firstIntersecting) {
|
|
// Set the index to the closest "snappable" slide
|
|
this.activeSlide =
|
|
(Math.ceil(normalizedIndex / this.slidesPerMove) * this.slidesPerMove + slidesCount) % slidesCount;
|
|
|
|
if (!this.scrolling) {
|
|
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, 'instant');
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
root: this.scrollContainer,
|
|
threshold: 0.6,
|
|
},
|
|
);
|
|
|
|
this.getSlides({ excludeClones: false }).forEach(slide => {
|
|
io.observe(slide);
|
|
});
|
|
}
|
|
|
|
private handleScrollEnd() {
|
|
if (!this.scrolling || this.dragging) return;
|
|
|
|
this.synchronizeSlides();
|
|
|
|
this.scrolling = false;
|
|
this.pendingSlideChange = false;
|
|
this.synchronizeSlides();
|
|
}
|
|
|
|
private isCarouselItem(node: Node): node is WaCarouselItem {
|
|
return node instanceof Element && node.tagName.toLowerCase() === 'wa-carousel-item';
|
|
}
|
|
|
|
private handleSlotChange = (mutations: MutationRecord[]) => {
|
|
const needsInitialization = mutations.some(mutation =>
|
|
[...mutation.addedNodes, ...mutation.removedNodes].some(
|
|
(el: HTMLElement) => this.isCarouselItem(el) && !el.hasAttribute('data-clone'),
|
|
),
|
|
);
|
|
|
|
// Reinitialize the carousel if a carousel item has been added or removed
|
|
if (needsInitialization) {
|
|
this.initializeSlides();
|
|
}
|
|
|
|
this.requestUpdate();
|
|
};
|
|
|
|
@watch('loop', { waitUntilFirstUpdate: true })
|
|
@watch('slidesPerPage', { waitUntilFirstUpdate: true })
|
|
initializeSlides() {
|
|
// Removes all the cloned elements from the carousel
|
|
this.getSlides({ excludeClones: false }).forEach((slide, index) => {
|
|
slide.classList.remove('--in-view');
|
|
slide.classList.remove('--is-active');
|
|
slide.setAttribute('aria-label', this.localize.term('slideNum', index + 1));
|
|
|
|
if (slide.hasAttribute('data-clone')) {
|
|
slide.remove();
|
|
}
|
|
});
|
|
|
|
this.updateSlidesSnap();
|
|
|
|
if (this.loop) {
|
|
// Creates clones to be placed before and after the original elements to simulate infinite scrolling
|
|
this.createClones();
|
|
}
|
|
|
|
// Because the DOM may be changed, restore the scroll position to the active slide
|
|
this.goToSlide(this.activeSlide, 'auto');
|
|
|
|
this.synchronizeSlides();
|
|
}
|
|
|
|
private createClones() {
|
|
const slides = this.getSlides();
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
@watch('activeSlide')
|
|
handleSlideChange() {
|
|
const slides = this.getSlides();
|
|
slides.forEach((slide, i) => {
|
|
slide.classList.toggle('--is-active', i === this.activeSlide);
|
|
});
|
|
|
|
// Do not emit an event on first render
|
|
if (this.hasUpdated) {
|
|
this.dispatchEvent(
|
|
new WaSlideChangeEvent({
|
|
index: this.activeSlide,
|
|
slide: slides[this.activeSlide],
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
@watch('slidesPerMove')
|
|
updateSlidesSnap() {
|
|
const slides = this.getSlides();
|
|
|
|
const slidesPerMove = this.slidesPerMove;
|
|
slides.forEach((slide, i) => {
|
|
const shouldSnap = (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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Move the carousel backward 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 forward 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 slides = this.getSlides();
|
|
const slidesWithClones = this.getSlides({ excludeClones: false });
|
|
|
|
// No need to do anything in case there are no items in the carousel
|
|
if (!slides.length) {
|
|
return;
|
|
}
|
|
|
|
// Sets the next index without taking into account clones, if any.
|
|
const newActiveSlide = loop
|
|
? (index + slides.length) % slides.length
|
|
: clamp(index, 0, slides.length - slidesPerPage);
|
|
this.activeSlide = newActiveSlide;
|
|
|
|
const isRtl = this.localize.dir() === 'rtl';
|
|
|
|
// Get the index of the next slide. For looping carousel it adds `slidesPerPage`
|
|
// to normalize the starting index in order to ignore the first nth clones.
|
|
// For RTL it needs to scroll to the last slide of the page.
|
|
const nextSlideIndex = clamp(
|
|
index + (loop ? slidesPerPage : 0) + (isRtl ? slidesPerPage - 1 : 0),
|
|
0,
|
|
slidesWithClones.length - 1,
|
|
);
|
|
|
|
const nextSlide = slidesWithClones[nextSlideIndex];
|
|
|
|
this.scrollToSlide(nextSlide, prefersReducedMotion() ? 'auto' : behavior);
|
|
}
|
|
|
|
private scrollToSlide(slide: HTMLElement, behavior: ScrollBehavior = 'smooth') {
|
|
// Since the geometry doesn't happen until rAF, we don't know if we'll be scrolling or not...
|
|
// It's best to assume that we will and cleanup in the else case below if we didn't need to
|
|
this.pendingSlideChange = true;
|
|
window.requestAnimationFrame(() => {
|
|
// This can happen if goToSlide is called before the scroll container is rendered
|
|
// We will have correctly set the activeSlide in goToSlide which will get picked up when initializeSlides is called.
|
|
if (!this.scrollContainer) {
|
|
return;
|
|
}
|
|
|
|
const scrollContainer = this.scrollContainer;
|
|
const scrollContainerRect = scrollContainer.getBoundingClientRect();
|
|
const nextSlideRect = slide.getBoundingClientRect();
|
|
|
|
const nextLeft = nextSlideRect.left - scrollContainerRect.left;
|
|
const nextTop = nextSlideRect.top - scrollContainerRect.top;
|
|
|
|
if (nextLeft || nextTop) {
|
|
// This is here just in case someone set it back to false
|
|
// between rAF being requested and the callback actually running
|
|
this.pendingSlideChange = true;
|
|
scrollContainer.scrollTo({
|
|
left: nextLeft + scrollContainer.scrollLeft,
|
|
top: nextTop + scrollContainer.scrollTop,
|
|
behavior,
|
|
});
|
|
} else {
|
|
this.pendingSlideChange = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
render() {
|
|
const { slidesPerMove, scrolling } = this;
|
|
|
|
let pagesCount = 0;
|
|
let currentPage = 0;
|
|
let prevEnabled = false;
|
|
let nextEnabled = false;
|
|
|
|
// @TODO: This is a super hacky way to get rid of hydration mismatch errors. The ideal solution is users being able to pass in `pagesCount` and `currentPage` and then on firstUpdated to we update the value for them.
|
|
if (this.hasUpdated) {
|
|
pagesCount = this.getPageCount();
|
|
currentPage = this.getCurrentPage();
|
|
prevEnabled = this.canScrollPrev();
|
|
nextEnabled = this.canScrollNext();
|
|
}
|
|
|
|
// We can't rely on `this.matches()` on the server.
|
|
const isRTL = isServer ? this.dir === 'rtl' : this.localize.dir() === 'rtl';
|
|
|
|
return html`
|
|
<div part="base" class="carousel">
|
|
<div
|
|
id="scroll-container"
|
|
part="scroll-container"
|
|
class="${classMap({
|
|
carousel__slides: true,
|
|
'carousel__slides--horizontal': this.orientation === 'horizontal',
|
|
'carousel__slides--vertical': this.orientation === 'vertical',
|
|
'carousel__slides--dragging': this.dragging,
|
|
})}"
|
|
style="--slides-per-page: ${this.slidesPerPage};"
|
|
aria-busy="${scrolling ? 'true' : 'false'}"
|
|
aria-atomic="true"
|
|
tabindex="0"
|
|
@keydown=${this.handleKeyDown}
|
|
@mousedown="${this.handleMouseDragStart}"
|
|
@scroll="${this.handleScroll}"
|
|
@scrollend=${this.handleScrollEnd}
|
|
@click=${this.handleClick}
|
|
>
|
|
<slot @slotchange=${() => this.requestUpdate()}></slot>
|
|
</div>
|
|
|
|
${this.navigation
|
|
? html`
|
|
<div part="navigation" class="carousel__navigation">
|
|
<button
|
|
part="navigation-button navigation-button-previous"
|
|
class="${classMap({
|
|
'carousel__navigation-button': true,
|
|
'carousel__navigation-button-previous': true,
|
|
'carousel__navigation-button-disabled': !prevEnabled,
|
|
})}"
|
|
aria-label="${this.localize.term('previousSlide')}"
|
|
aria-controls="scroll-container"
|
|
aria-disabled="${prevEnabled ? 'false' : 'true'}"
|
|
@click=${prevEnabled ? () => this.previous() : null}
|
|
>
|
|
<slot name="previous-icon">
|
|
<wa-icon library="system" name="${isRTL ? 'chevron-right' : 'chevron-left'}"></wa-icon>
|
|
</slot>
|
|
</button>
|
|
|
|
<button
|
|
part="navigation-button navigation-button-next"
|
|
class=${classMap({
|
|
'carousel__navigation-button': true,
|
|
'carousel__navigation-button-next': true,
|
|
'carousel__navigation-button-disabled': !nextEnabled,
|
|
})}
|
|
aria-label="${this.localize.term('nextSlide')}"
|
|
aria-controls="scroll-container"
|
|
aria-disabled="${nextEnabled ? 'false' : 'true'}"
|
|
@click=${nextEnabled ? () => this.next() : null}
|
|
>
|
|
<slot name="next-icon">
|
|
<wa-icon library="system" name="${isRTL ? 'chevron-left' : 'chevron-right'}"></wa-icon>
|
|
</slot>
|
|
</button>
|
|
</div>
|
|
`
|
|
: ''}
|
|
${this.pagination
|
|
? html`
|
|
<div part="pagination" role="tablist" class="carousel__pagination" aria-controls="scroll-container">
|
|
${map(range(pagesCount), index => {
|
|
const isActive = index === currentPage;
|
|
return html`
|
|
<button
|
|
part="pagination-item ${isActive ? 'pagination-item--active' : ''}"
|
|
class="${classMap({
|
|
'carousel__pagination-item': true,
|
|
'carousel__pagination-item--active': isActive,
|
|
})}"
|
|
role="tab"
|
|
aria-selected="${isActive ? 'true' : 'false'}"
|
|
aria-label="${this.localize.term('goToSlide', index + 1, pagesCount)}"
|
|
tabindex=${isActive ? '0' : '-1'}
|
|
@click=${() => this.goToSlide(index * slidesPerMove)}
|
|
@keydown=${this.handleKeyDown}
|
|
></button>
|
|
`;
|
|
})}
|
|
</div>
|
|
`
|
|
: html``}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'wa-carousel': WaCarousel;
|
|
}
|
|
}
|