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

fix: multiple slides per page navigation #1605

Merged
merged 12 commits into from
Oct 23, 2023
4 changes: 0 additions & 4 deletions src/components/carousel-item/carousel-item.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,6 @@ import type { CSSResultGroup } from 'lit';
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', 'group');
Expand Down
131 changes: 82 additions & 49 deletions src/components/carousel/carousel.component.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import '../../internal/scrollend-polyfill.js';

import { AutoplayController } from './autoplay-controller.js';
import { clamp } from '../../internal/math.js';
import { classMap } from 'lit/directives/class-map.js';
Expand All @@ -10,10 +12,10 @@ import { range } from 'lit/directives/range.js';
import { ScrollController } from './scroll-controller.js';
import { watch } from '../../internal/watch.js';
import ShoelaceElement from '../../internal/shoelace-element.js';
import SlCarouselItem from '../carousel-item/carousel-item.component.js';
import SlIcon from '../icon/icon.component.js';
import styles from './carousel.styles.js';
import type { CSSResultGroup } from 'lit';
import type { CSSResultGroup, PropertyValueMap } from 'lit';
import type SlCarouselItem from '../carousel-item/carousel-item.component.js';

/**
* @summary Carousels display an arbitrary number of content slides along a horizontal or vertical axis.
Expand Down Expand Up @@ -68,7 +70,7 @@ export default class SlCarousel extends ShoelaceElement {

/**
* Specifies the number of slides the carousel will advance when scrolling, useful when specifying a `slides-per-page`
* greater than one.
* greater than one. It can't be higher than `slides-per-page`.
*/
@property({ type: Number, attribute: 'slides-per-move' }) slidesPerMove = 1;

Expand All @@ -78,7 +80,6 @@ export default class SlCarousel extends ShoelaceElement {
/** 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;

Expand All @@ -87,7 +88,6 @@ export default class SlCarousel extends ShoelaceElement {

private autoplayController = new AutoplayController(this, () => this.next());
private scrollController = new ScrollController(this);
private readonly slides = this.getElementsByTagName('sl-carousel-item');
private intersectionObserver: IntersectionObserver; // determines which slide is displayed
// A map containing the state of all the slides
private readonly intersectionObserverEntries = new Map<Element, IntersectionObserverEntry>();
Expand Down Expand Up @@ -133,19 +133,45 @@ export default class SlCarousel extends ShoelaceElement {
protected firstUpdated(): void {
this.initializeSlides();
this.mutationObserver = new MutationObserver(this.handleSlotChange);
this.mutationObserver.observe(this, { childList: true, subtree: false });
this.mutationObserver.observe(this, {
childList: true,
subtree: true
});
}

protected willUpdate(changedProperties: PropertyValueMap<SlCarousel> | 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() {
return Math.ceil(this.getSlides().length / this.slidesPerPage);
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.slidesPerPage);
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.slides].filter(slide => !excludeClones || !slide.hasAttribute('data-clone'));
return [...this.children].filter(
(el: HTMLElement) => this.isCarouselItem(el) && (!excludeClones || !el.hasAttribute('data-clone'))
) as SlCarouselItem[];
}

private handleKeyDown(event: KeyboardEvent) {
Expand Down Expand Up @@ -201,34 +227,36 @@ export default class SlCarousel extends ShoelaceElement {

// Scrolls to the original slide without animating, so the user won't notice that the position has changed
this.goToSlide(clonePosition, 'auto');

return;
} else if (firstIntersecting) {
// Update the current index based on the first visible slide
const slideIndex = slides.indexOf(firstIntersecting.target as SlCarouselItem);
// Set the index to the first "snappable" slide
this.activeSlide = Math.ceil(slideIndex / this.slidesPerMove) * this.slidesPerMove;
}
}

// Activate the first intersecting slide
if (firstIntersecting) {
this.activeSlide = slides.indexOf(firstIntersecting.target as SlCarouselItem);
}
private isCarouselItem(node: HTMLElement): node is SlCarouselItem {
return node.tagName.toLowerCase() === 'sl-carousel-item';
claviska marked this conversation as resolved.
Show resolved Hide resolved
}

private handleSlotChange = (mutations: MutationRecord[]) => {
const needsInitialization = mutations.some(mutation =>
[...mutation.addedNodes, ...mutation.removedNodes].some(
node => SlCarouselItem.isCarouselItem(node) && !(node as HTMLElement).hasAttribute('data-clone')
(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() {
const slides = this.getSlides();
const intersectionObserver = this.intersectionObserver;

this.intersectionObserverEntries.clear();
Expand All @@ -246,23 +274,11 @@ export default class SlCarousel extends ShoelaceElement {
}
});

this.updateSlidesSnap();

if (this.loop) {
// Creates clones to be placed before and after the original elements to simulate 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.createClones();
}

this.getSlides({ excludeClones: false }).forEach(slide => {
Expand All @@ -273,6 +289,26 @@ export default class SlCarousel extends ShoelaceElement {
this.goToSlide(this.activeSlide, 'auto');
}

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')
handelSlideChange() {
const slides = this.getSlides();
Expand All @@ -292,12 +328,12 @@ export default class SlCarousel extends ShoelaceElement {
}

@watch('slidesPerMove')
handleSlidesPerMoveChange() {
const slides = this.getSlides({ excludeClones: false });
updateSlidesSnap() {
const slides = this.getSlides();

const slidesPerMove = this.slidesPerMove;
slides.forEach((slide, i) => {
const shouldSnap = Math.abs(i - slidesPerMove) % slidesPerMove === 0;
const shouldSnap = (i + slidesPerMove) % slidesPerMove === 0;
if (shouldSnap) {
slide.style.removeProperty('scroll-snap-align');
} else {
Expand Down Expand Up @@ -325,15 +361,7 @@ export default class SlCarousel extends ShoelaceElement {
* @param behavior - The behavior used for scrolling.
*/
previous(behavior: ScrollBehavior = 'smooth') {
let previousIndex = this.activeSlide || this.activeSlide - this.slidesPerMove;
let canSnap = false;

while (!canSnap && previousIndex > 0) {
previousIndex -= 1;
canSnap = Math.abs(previousIndex - this.slidesPerMove) % this.slidesPerMove === 0;
}

this.goToSlide(previousIndex, behavior);
this.goToSlide(this.activeSlide - this.slidesPerMove, behavior);
}

/**
Expand All @@ -357,8 +385,13 @@ export default class SlCarousel extends ShoelaceElement {
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 = (index + slides.length) % slides.length;
const newActiveSlide = loop ? (index + slides.length) % slides.length : clamp(index, 0, slides.length - 1);
this.activeSlide = newActiveSlide;

// Get the index of the next slide. For looping carousel it adds `slidesPerPage`
Expand All @@ -377,11 +410,11 @@ export default class SlCarousel extends ShoelaceElement {
}

render() {
const { scrollController, slidesPerPage } = this;
const { scrollController, slidesPerMove } = this;
const pagesCount = this.getPageCount();
const currentPage = this.getCurrentPage();
const prevEnabled = this.loop || currentPage > 0;
const nextEnabled = this.loop || currentPage < pagesCount - 1;
const prevEnabled = this.canScrollPrev();
const nextEnabled = this.canScrollNext();
const isLtr = this.localize.dir() === 'ltr';

return html`
Expand Down Expand Up @@ -459,7 +492,7 @@ export default class SlCarousel extends ShoelaceElement {
aria-selected="${isActive ? 'true' : 'false'}"
aria-label="${this.localize.term('goToSlide', index + 1, pagesCount)}"
tabindex=${isActive ? '0' : '-1'}
@click=${() => this.goToSlide(index * slidesPerPage)}
@click=${() => this.goToSlide(index * slidesPerMove)}
@keydown=${this.handleKeyDown}
></button>
`;
Expand Down
Loading