Skip to content

Commit

Permalink
fix: multiple slides per page navigation (#1605)
Browse files Browse the repository at this point in the history
* fix(carousel): change navigation logic

* chore: update tests

* chore: create polyfill for scrollend

* chore: add unit tests and clean up

* chore: leftover

* chore: minor fix

* chore: avoid initialization for clones

* fix(carousel): update page navigation logic

* chore(carousel): revert change

* chore(carousel): minor changes

* chore: update pagination logic

* fix: enforce slidesPerMove value
  • Loading branch information
alenaksu committed Oct 23, 2023
1 parent f53309b commit 58bf054
Show file tree
Hide file tree
Showing 5 changed files with 289 additions and 103 deletions.
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';
}

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

0 comments on commit 58bf054

Please sign in to comment.