kopia lustrzana https://github.com/shoelace-style/shoelace
fix: multiple slides per page navigation (#1605)
* 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 valuepull/1684/head
rodzic
f53309b04a
commit
58bf05451d
|
@ -17,10 +17,6 @@ import type { CSSResultGroup } from 'lit';
|
||||||
export default class SlCarouselItem extends ShoelaceElement {
|
export default class SlCarouselItem extends ShoelaceElement {
|
||||||
static styles: CSSResultGroup = styles;
|
static styles: CSSResultGroup = styles;
|
||||||
|
|
||||||
static isCarouselItem(node: Node) {
|
|
||||||
return node instanceof Element && node.getAttribute('aria-roledescription') === 'slide';
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.setAttribute('role', 'group');
|
this.setAttribute('role', 'group');
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import '../../internal/scrollend-polyfill.js';
|
||||||
|
|
||||||
import { AutoplayController } from './autoplay-controller.js';
|
import { AutoplayController } from './autoplay-controller.js';
|
||||||
import { clamp } from '../../internal/math.js';
|
import { clamp } from '../../internal/math.js';
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
|
@ -10,10 +12,10 @@ import { range } from 'lit/directives/range.js';
|
||||||
import { ScrollController } from './scroll-controller.js';
|
import { ScrollController } from './scroll-controller.js';
|
||||||
import { watch } from '../../internal/watch.js';
|
import { watch } from '../../internal/watch.js';
|
||||||
import ShoelaceElement from '../../internal/shoelace-element.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 SlIcon from '../icon/icon.component.js';
|
||||||
import styles from './carousel.styles.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.
|
* @summary Carousels display an arbitrary number of content slides along a horizontal or vertical axis.
|
||||||
|
@ -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`
|
* 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;
|
@property({ type: Number, attribute: 'slides-per-move' }) slidesPerMove = 1;
|
||||||
|
|
||||||
|
@ -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. */
|
/** 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;
|
@property({ type: Boolean, reflect: true, attribute: 'mouse-dragging' }) mouseDragging = false;
|
||||||
|
|
||||||
@query('slot:not([name])') defaultSlot: HTMLSlotElement;
|
|
||||||
@query('.carousel__slides') scrollContainer: HTMLElement;
|
@query('.carousel__slides') scrollContainer: HTMLElement;
|
||||||
@query('.carousel__pagination') paginationContainer: HTMLElement;
|
@query('.carousel__pagination') paginationContainer: HTMLElement;
|
||||||
|
|
||||||
|
@ -87,7 +88,6 @@ export default class SlCarousel extends ShoelaceElement {
|
||||||
|
|
||||||
private autoplayController = new AutoplayController(this, () => this.next());
|
private autoplayController = new AutoplayController(this, () => this.next());
|
||||||
private scrollController = new ScrollController(this);
|
private scrollController = new ScrollController(this);
|
||||||
private readonly slides = this.getElementsByTagName('sl-carousel-item');
|
|
||||||
private intersectionObserver: IntersectionObserver; // determines which slide is displayed
|
private intersectionObserver: IntersectionObserver; // determines which slide is displayed
|
||||||
// A map containing the state of all the slides
|
// A map containing the state of all the slides
|
||||||
private readonly intersectionObserverEntries = new Map<Element, IntersectionObserverEntry>();
|
private readonly intersectionObserverEntries = new Map<Element, IntersectionObserverEntry>();
|
||||||
|
@ -133,19 +133,45 @@ export default class SlCarousel extends ShoelaceElement {
|
||||||
protected firstUpdated(): void {
|
protected firstUpdated(): void {
|
||||||
this.initializeSlides();
|
this.initializeSlides();
|
||||||
this.mutationObserver = new MutationObserver(this.handleSlotChange);
|
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() {
|
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() {
|
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 } = {}) {
|
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) {
|
private handleKeyDown(event: KeyboardEvent) {
|
||||||
|
@ -201,20 +227,22 @@ export default class SlCarousel extends ShoelaceElement {
|
||||||
|
|
||||||
// Scrolls to the original slide without animating, so the user won't notice that the position has changed
|
// Scrolls to the original slide without animating, so the user won't notice that the position has changed
|
||||||
this.goToSlide(clonePosition, 'auto');
|
this.goToSlide(clonePosition, 'auto');
|
||||||
|
} else if (firstIntersecting) {
|
||||||
return;
|
// 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
|
private isCarouselItem(node: HTMLElement): node is SlCarouselItem {
|
||||||
if (firstIntersecting) {
|
return node.tagName.toLowerCase() === 'sl-carousel-item';
|
||||||
this.activeSlide = slides.indexOf(firstIntersecting.target as SlCarouselItem);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleSlotChange = (mutations: MutationRecord[]) => {
|
private handleSlotChange = (mutations: MutationRecord[]) => {
|
||||||
const needsInitialization = mutations.some(mutation =>
|
const needsInitialization = mutations.some(mutation =>
|
||||||
[...mutation.addedNodes, ...mutation.removedNodes].some(
|
[...mutation.addedNodes, ...mutation.removedNodes].some(
|
||||||
node => SlCarouselItem.isCarouselItem(node) && !(node as HTMLElement).hasAttribute('data-clone')
|
(el: HTMLElement) => this.isCarouselItem(el) && !el.hasAttribute('data-clone')
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -222,13 +250,13 @@ export default class SlCarousel extends ShoelaceElement {
|
||||||
if (needsInitialization) {
|
if (needsInitialization) {
|
||||||
this.initializeSlides();
|
this.initializeSlides();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
@watch('loop', { waitUntilFirstUpdate: true })
|
@watch('loop', { waitUntilFirstUpdate: true })
|
||||||
@watch('slidesPerPage', { waitUntilFirstUpdate: true })
|
@watch('slidesPerPage', { waitUntilFirstUpdate: true })
|
||||||
initializeSlides() {
|
initializeSlides() {
|
||||||
const slides = this.getSlides();
|
|
||||||
const intersectionObserver = this.intersectionObserver;
|
const intersectionObserver = this.intersectionObserver;
|
||||||
|
|
||||||
this.intersectionObserverEntries.clear();
|
this.intersectionObserverEntries.clear();
|
||||||
|
@ -246,8 +274,24 @@ export default class SlCarousel extends ShoelaceElement {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.updateSlidesSnap();
|
||||||
|
|
||||||
if (this.loop) {
|
if (this.loop) {
|
||||||
// Creates clones to be placed before and after the original elements to simulate infinite scrolling
|
// Creates clones to be placed before and after the original elements to simulate infinite scrolling
|
||||||
|
this.createClones();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.getSlides({ excludeClones: false }).forEach(slide => {
|
||||||
|
intersectionObserver.observe(slide);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Because the DOM may be changed, restore the scroll position to the active slide
|
||||||
|
this.goToSlide(this.activeSlide, 'auto');
|
||||||
|
}
|
||||||
|
|
||||||
|
private createClones() {
|
||||||
|
const slides = this.getSlides();
|
||||||
|
|
||||||
const slidesPerPage = this.slidesPerPage;
|
const slidesPerPage = this.slidesPerPage;
|
||||||
const lastSlides = slides.slice(-slidesPerPage);
|
const lastSlides = slides.slice(-slidesPerPage);
|
||||||
const firstSlides = slides.slice(0, slidesPerPage);
|
const firstSlides = slides.slice(0, slidesPerPage);
|
||||||
|
@ -265,14 +309,6 @@ export default class SlCarousel extends ShoelaceElement {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.getSlides({ excludeClones: false }).forEach(slide => {
|
|
||||||
intersectionObserver.observe(slide);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Because the DOM may be changed, restore the scroll position to the active slide
|
|
||||||
this.goToSlide(this.activeSlide, 'auto');
|
|
||||||
}
|
|
||||||
|
|
||||||
@watch('activeSlide')
|
@watch('activeSlide')
|
||||||
handelSlideChange() {
|
handelSlideChange() {
|
||||||
const slides = this.getSlides();
|
const slides = this.getSlides();
|
||||||
|
@ -292,12 +328,12 @@ export default class SlCarousel extends ShoelaceElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
@watch('slidesPerMove')
|
@watch('slidesPerMove')
|
||||||
handleSlidesPerMoveChange() {
|
updateSlidesSnap() {
|
||||||
const slides = this.getSlides({ excludeClones: false });
|
const slides = this.getSlides();
|
||||||
|
|
||||||
const slidesPerMove = this.slidesPerMove;
|
const slidesPerMove = this.slidesPerMove;
|
||||||
slides.forEach((slide, i) => {
|
slides.forEach((slide, i) => {
|
||||||
const shouldSnap = Math.abs(i - slidesPerMove) % slidesPerMove === 0;
|
const shouldSnap = (i + slidesPerMove) % slidesPerMove === 0;
|
||||||
if (shouldSnap) {
|
if (shouldSnap) {
|
||||||
slide.style.removeProperty('scroll-snap-align');
|
slide.style.removeProperty('scroll-snap-align');
|
||||||
} else {
|
} else {
|
||||||
|
@ -325,15 +361,7 @@ export default class SlCarousel extends ShoelaceElement {
|
||||||
* @param behavior - The behavior used for scrolling.
|
* @param behavior - The behavior used for scrolling.
|
||||||
*/
|
*/
|
||||||
previous(behavior: ScrollBehavior = 'smooth') {
|
previous(behavior: ScrollBehavior = 'smooth') {
|
||||||
let previousIndex = this.activeSlide || this.activeSlide - this.slidesPerMove;
|
this.goToSlide(this.activeSlide - this.slidesPerMove, behavior);
|
||||||
let canSnap = false;
|
|
||||||
|
|
||||||
while (!canSnap && previousIndex > 0) {
|
|
||||||
previousIndex -= 1;
|
|
||||||
canSnap = Math.abs(previousIndex - this.slidesPerMove) % this.slidesPerMove === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.goToSlide(previousIndex, behavior);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -357,8 +385,13 @@ export default class SlCarousel extends ShoelaceElement {
|
||||||
const slides = this.getSlides();
|
const slides = this.getSlides();
|
||||||
const slidesWithClones = this.getSlides({ excludeClones: false });
|
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.
|
// 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;
|
this.activeSlide = newActiveSlide;
|
||||||
|
|
||||||
// Get the index of the next slide. For looping carousel it adds `slidesPerPage`
|
// Get the index of the next slide. For looping carousel it adds `slidesPerPage`
|
||||||
|
@ -377,11 +410,11 @@ export default class SlCarousel extends ShoelaceElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { scrollController, slidesPerPage } = this;
|
const { scrollController, slidesPerMove } = this;
|
||||||
const pagesCount = this.getPageCount();
|
const pagesCount = this.getPageCount();
|
||||||
const currentPage = this.getCurrentPage();
|
const currentPage = this.getCurrentPage();
|
||||||
const prevEnabled = this.loop || currentPage > 0;
|
const prevEnabled = this.canScrollPrev();
|
||||||
const nextEnabled = this.loop || currentPage < pagesCount - 1;
|
const nextEnabled = this.canScrollNext();
|
||||||
const isLtr = this.localize.dir() === 'ltr';
|
const isLtr = this.localize.dir() === 'ltr';
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
|
@ -459,7 +492,7 @@ export default class SlCarousel extends ShoelaceElement {
|
||||||
aria-selected="${isActive ? 'true' : 'false'}"
|
aria-selected="${isActive ? 'true' : 'false'}"
|
||||||
aria-label="${this.localize.term('goToSlide', index + 1, pagesCount)}"
|
aria-label="${this.localize.term('goToSlide', index + 1, pagesCount)}"
|
||||||
tabindex=${isActive ? '0' : '-1'}
|
tabindex=${isActive ? '0' : '-1'}
|
||||||
@click=${() => this.goToSlide(index * slidesPerPage)}
|
@click=${() => this.goToSlide(index * slidesPerMove)}
|
||||||
@keydown=${this.handleKeyDown}
|
@keydown=${this.handleKeyDown}
|
||||||
></button>
|
></button>
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import '../../../dist/shoelace.js';
|
import '../../../dist/shoelace.js';
|
||||||
import { clickOnElement } from '../../internal/test.js';
|
import { clickOnElement } from '../../internal/test.js';
|
||||||
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
|
import { expect, fixture, html, oneEvent } from '@open-wc/testing';
|
||||||
|
import { map } from 'lit/directives/map.js';
|
||||||
|
import { range } from 'lit/directives/range.js';
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
import type SlCarousel from './carousel.js';
|
import type SlCarousel from './carousel.js';
|
||||||
|
|
||||||
|
@ -223,6 +225,36 @@ describe('<sl-carousel>', () => {
|
||||||
// Assert
|
// Assert
|
||||||
expect(el.scrollContainer.style.getPropertyValue('--slides-per-page').trim()).to.be.equal('2');
|
expect(el.scrollContainer.style.getPropertyValue('--slides-per-page').trim()).to.be.equal('2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
[
|
||||||
|
[7, 2, 1, false, 6],
|
||||||
|
[5, 3, 3, false, 2],
|
||||||
|
[10, 2, 2, false, 5],
|
||||||
|
[7, 2, 1, true, 7],
|
||||||
|
[5, 3, 3, true, 2],
|
||||||
|
[10, 2, 2, true, 5]
|
||||||
|
].forEach(([slides, slidesPerPage, slidesPerMove, loop, expected]: [number, number, number, boolean, number]) => {
|
||||||
|
it(`should display ${expected} pages for ${slides} slides grouped by ${slidesPerPage} and scrolled by ${slidesPerMove}${
|
||||||
|
loop ? ' (loop)' : ''
|
||||||
|
}`, async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel
|
||||||
|
pagination
|
||||||
|
navigation
|
||||||
|
slides-per-page="${slidesPerPage}"
|
||||||
|
slides-per-move="${slidesPerMove}"
|
||||||
|
?loop=${loop}
|
||||||
|
>
|
||||||
|
${map(range(slides), i => html`<sl-carousel-item>${i}</sl-carousel-item>`)}
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
const paginationItems = el.shadowRoot!.querySelectorAll('.carousel__pagination-item');
|
||||||
|
expect(paginationItems.length).to.equal(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when `slides-per-move` attribute is provided', () => {
|
describe('when `slides-per-move` attribute is provided', () => {
|
||||||
|
@ -230,7 +262,7 @@ describe('<sl-carousel>', () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const expectedSnapGranularity = 2;
|
const expectedSnapGranularity = 2;
|
||||||
const el = await fixture<SlCarousel>(html`
|
const el = await fixture<SlCarousel>(html`
|
||||||
<sl-carousel slides-per-move="${expectedSnapGranularity}">
|
<sl-carousel slides-per-page="${expectedSnapGranularity}" slides-per-move="${expectedSnapGranularity}">
|
||||||
<sl-carousel-item>Node 1</sl-carousel-item>
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
<sl-carousel-item>Node 2</sl-carousel-item>
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
<sl-carousel-item>Node 3</sl-carousel-item>
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
@ -252,6 +284,89 @@ describe('<sl-carousel>', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should be possible to move by the given number of slides at a time', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel navigation slides-per-move="2" slides-per-page="2">
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item class="expected">Node 3</sl-carousel-item>
|
||||||
|
<sl-carousel-item class="expected">Node 4</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 5</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 6</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
const expectedSlides = el.querySelectorAll('.expected')!;
|
||||||
|
const nextButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--next')!;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await clickOnElement(nextButton);
|
||||||
|
|
||||||
|
await oneEvent(el.scrollContainer, 'scrollend');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
for (const expectedSlide of expectedSlides) {
|
||||||
|
expect(expectedSlide).to.have.class('--in-view');
|
||||||
|
expect(expectedSlide).to.be.visible;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be possible to move by a number that is less than the displayed number', async () => {
|
||||||
|
// Arrange
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel navigation slides-per-move="1" slides-per-page="2">
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 4</sl-carousel-item>
|
||||||
|
<sl-carousel-item class="expected">Node 5</sl-carousel-item>
|
||||||
|
<sl-carousel-item class="expected">Node 6</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
const expectedSlides = el.querySelectorAll('.expected')!;
|
||||||
|
const nextButton: HTMLElement = el.shadowRoot!.querySelector('.carousel__navigation-button--next')!;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await clickOnElement(nextButton);
|
||||||
|
await clickOnElement(nextButton);
|
||||||
|
await clickOnElement(nextButton);
|
||||||
|
await clickOnElement(nextButton);
|
||||||
|
await clickOnElement(nextButton);
|
||||||
|
await clickOnElement(nextButton);
|
||||||
|
|
||||||
|
await oneEvent(el.scrollContainer, 'scrollend');
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
for (const expectedSlide of expectedSlides) {
|
||||||
|
expect(expectedSlide).to.have.class('--in-view');
|
||||||
|
expect(expectedSlide).to.be.visible;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not be possible to move by a number that is greater than the displayed number', async () => {
|
||||||
|
// Arrange
|
||||||
|
const expectedSlidesPerMove = 2;
|
||||||
|
const el = await fixture<SlCarousel>(html`
|
||||||
|
<sl-carousel slides-per-page="${expectedSlidesPerMove}">
|
||||||
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 4</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 5</sl-carousel-item>
|
||||||
|
<sl-carousel-item>Node 6</sl-carousel-item>
|
||||||
|
</sl-carousel>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
el.slidesPerMove = 3;
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(el.slidesPerMove).to.be.equal(expectedSlidesPerMove);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when `orientation` attribute is provided', () => {
|
describe('when `orientation` attribute is provided', () => {
|
||||||
|
@ -465,7 +580,7 @@ describe('<sl-carousel>', () => {
|
||||||
it('should scroll the carousel to the next slide', async () => {
|
it('should scroll the carousel to the next slide', async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const el = await fixture<SlCarousel>(html`
|
const el = await fixture<SlCarousel>(html`
|
||||||
<sl-carousel slides-per-move="2">
|
<sl-carousel slides-per-page="2" slides-per-move="2">
|
||||||
<sl-carousel-item>Node 1</sl-carousel-item>
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
<sl-carousel-item>Node 2</sl-carousel-item>
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
<sl-carousel-item>Node 3</sl-carousel-item>
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
@ -485,7 +600,7 @@ describe('<sl-carousel>', () => {
|
||||||
it('should scroll the carousel to the previous slide', async () => {
|
it('should scroll the carousel to the previous slide', async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const el = await fixture<SlCarousel>(html`
|
const el = await fixture<SlCarousel>(html`
|
||||||
<sl-carousel slides-per-move="2">
|
<sl-carousel slides-per-page="2" slides-per-move="2">
|
||||||
<sl-carousel-item>Node 1</sl-carousel-item>
|
<sl-carousel-item>Node 1</sl-carousel-item>
|
||||||
<sl-carousel-item>Node 2</sl-carousel-item>
|
<sl-carousel-item>Node 2</sl-carousel-item>
|
||||||
<sl-carousel-item>Node 3</sl-carousel-item>
|
<sl-carousel-item>Node 3</sl-carousel-item>
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { debounce } from '../../internal/debounce.js';
|
|
||||||
import { prefersReducedMotion } from '../../internal/animate.js';
|
import { prefersReducedMotion } from '../../internal/animate.js';
|
||||||
import { waitForEvent } from '../../internal/event.js';
|
import { waitForEvent } from '../../internal/event.js';
|
||||||
import type { ReactiveController, ReactiveElement } from 'lit';
|
import type { ReactiveController, ReactiveElement } from 'lit';
|
||||||
|
@ -12,7 +11,6 @@ interface ScrollHost extends ReactiveElement {
|
||||||
*/
|
*/
|
||||||
export class ScrollController<T extends ScrollHost> implements ReactiveController {
|
export class ScrollController<T extends ScrollHost> implements ReactiveController {
|
||||||
private host: T;
|
private host: T;
|
||||||
private pointers = new Set();
|
|
||||||
|
|
||||||
dragging = false;
|
dragging = false;
|
||||||
scrolling = false;
|
scrolling = false;
|
||||||
|
@ -30,11 +28,10 @@ export class ScrollController<T extends ScrollHost> implements ReactiveControlle
|
||||||
const scrollContainer = host.scrollContainer;
|
const scrollContainer = host.scrollContainer;
|
||||||
|
|
||||||
scrollContainer.addEventListener('scroll', this.handleScroll, { passive: true });
|
scrollContainer.addEventListener('scroll', this.handleScroll, { passive: true });
|
||||||
|
scrollContainer.addEventListener('scrollend', this.handleScrollEnd, true);
|
||||||
scrollContainer.addEventListener('pointerdown', this.handlePointerDown);
|
scrollContainer.addEventListener('pointerdown', this.handlePointerDown);
|
||||||
scrollContainer.addEventListener('pointerup', this.handlePointerUp);
|
scrollContainer.addEventListener('pointerup', this.handlePointerUp);
|
||||||
scrollContainer.addEventListener('pointercancel', this.handlePointerUp);
|
scrollContainer.addEventListener('pointercancel', this.handlePointerUp);
|
||||||
scrollContainer.addEventListener('touchstart', this.handleTouchStart, { passive: true });
|
|
||||||
scrollContainer.addEventListener('touchend', this.handleTouchEnd);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hostDisconnected(): void {
|
hostDisconnected(): void {
|
||||||
|
@ -42,11 +39,10 @@ export class ScrollController<T extends ScrollHost> implements ReactiveControlle
|
||||||
const scrollContainer = host.scrollContainer;
|
const scrollContainer = host.scrollContainer;
|
||||||
|
|
||||||
scrollContainer.removeEventListener('scroll', this.handleScroll);
|
scrollContainer.removeEventListener('scroll', this.handleScroll);
|
||||||
|
scrollContainer.removeEventListener('scrollend', this.handleScrollEnd, true);
|
||||||
scrollContainer.removeEventListener('pointerdown', this.handlePointerDown);
|
scrollContainer.removeEventListener('pointerdown', this.handlePointerDown);
|
||||||
scrollContainer.removeEventListener('pointerup', this.handlePointerUp);
|
scrollContainer.removeEventListener('pointerup', this.handlePointerUp);
|
||||||
scrollContainer.removeEventListener('pointercancel', this.handlePointerUp);
|
scrollContainer.removeEventListener('pointercancel', this.handlePointerUp);
|
||||||
scrollContainer.removeEventListener('touchstart', this.handleTouchStart);
|
|
||||||
scrollContainer.removeEventListener('touchend', this.handleTouchEnd);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleScroll = () => {
|
handleScroll = () => {
|
||||||
|
@ -54,35 +50,22 @@ export class ScrollController<T extends ScrollHost> implements ReactiveControlle
|
||||||
this.scrolling = true;
|
this.scrolling = true;
|
||||||
this.host.requestUpdate();
|
this.host.requestUpdate();
|
||||||
}
|
}
|
||||||
this.handleScrollEnd();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@debounce(100)
|
handleScrollEnd = () => {
|
||||||
handleScrollEnd() {
|
if (this.scrolling && !this.dragging) {
|
||||||
if (!this.pointers.size) {
|
|
||||||
// If no pointer is active in the scroll area then the scroll has ended
|
|
||||||
this.scrolling = false;
|
this.scrolling = false;
|
||||||
this.host.scrollContainer.dispatchEvent(
|
|
||||||
new CustomEvent('scrollend', {
|
|
||||||
bubbles: false,
|
|
||||||
cancelable: false
|
|
||||||
})
|
|
||||||
);
|
|
||||||
this.host.requestUpdate();
|
this.host.requestUpdate();
|
||||||
} else {
|
|
||||||
// otherwise let's wait a bit more
|
|
||||||
this.handleScrollEnd();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
handlePointerDown = (event: PointerEvent) => {
|
handlePointerDown = (event: PointerEvent) => {
|
||||||
|
// Do not handle drag for touch interactions as scroll is natively supported
|
||||||
if (event.pointerType === 'touch') {
|
if (event.pointerType === 'touch') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pointers.add(event.pointerId);
|
const canDrag = this.mouseDragging && event.button === 0;
|
||||||
|
|
||||||
const canDrag = this.mouseDragging && !this.dragging && event.button === 0;
|
|
||||||
if (canDrag) {
|
if (canDrag) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
|
@ -105,24 +88,9 @@ export class ScrollController<T extends ScrollHost> implements ReactiveControlle
|
||||||
};
|
};
|
||||||
|
|
||||||
handlePointerUp = (event: PointerEvent) => {
|
handlePointerUp = (event: PointerEvent) => {
|
||||||
this.pointers.delete(event.pointerId);
|
|
||||||
this.host.scrollContainer.releasePointerCapture(event.pointerId);
|
this.host.scrollContainer.releasePointerCapture(event.pointerId);
|
||||||
|
|
||||||
if (this.pointers.size === 0) {
|
|
||||||
this.handleDragEnd();
|
this.handleDragEnd();
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleTouchEnd = (event: TouchEvent) => {
|
|
||||||
for (const touch of event.changedTouches) {
|
|
||||||
this.pointers.delete(touch.identifier);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleTouchStart = (event: TouchEvent) => {
|
|
||||||
for (const touch of event.touches) {
|
|
||||||
this.pointers.add(touch.identifier);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
handleDragStart() {
|
handleDragStart() {
|
||||||
|
@ -140,12 +108,11 @@ export class ScrollController<T extends ScrollHost> implements ReactiveControlle
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleDragEnd() {
|
handleDragEnd() {
|
||||||
const host = this.host;
|
const host = this.host;
|
||||||
const scrollContainer = host.scrollContainer;
|
const scrollContainer = host.scrollContainer;
|
||||||
|
|
||||||
scrollContainer.removeEventListener('pointermove', this.handlePointerMove);
|
scrollContainer.removeEventListener('pointermove', this.handlePointerMove);
|
||||||
this.dragging = false;
|
|
||||||
|
|
||||||
const startLeft = scrollContainer.scrollLeft;
|
const startLeft = scrollContainer.scrollLeft;
|
||||||
const startTop = scrollContainer.scrollTop;
|
const startTop = scrollContainer.scrollTop;
|
||||||
|
@ -158,12 +125,16 @@ export class ScrollController<T extends ScrollHost> implements ReactiveControlle
|
||||||
scrollContainer.scrollTo({ left: startLeft, top: startTop, behavior: 'auto' });
|
scrollContainer.scrollTo({ left: startLeft, top: startTop, behavior: 'auto' });
|
||||||
scrollContainer.scrollTo({ left: finalLeft, top: finalTop, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
|
scrollContainer.scrollTo({ left: finalLeft, top: finalTop, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
|
||||||
|
|
||||||
if (this.scrolling) {
|
// Wait for scroll to be applied
|
||||||
|
requestAnimationFrame(async () => {
|
||||||
|
if (startLeft !== finalLeft || startTop !== finalTop) {
|
||||||
await waitForEvent(scrollContainer, 'scrollend');
|
await waitForEvent(scrollContainer, 'scrollend');
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollContainer.style.removeProperty('scroll-snap-type');
|
scrollContainer.style.removeProperty('scroll-snap-type');
|
||||||
|
|
||||||
|
this.dragging = false;
|
||||||
host.requestUpdate();
|
host.requestUpdate();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,71 @@
|
||||||
|
type GenericCallback = (this: unknown, ...args: unknown[]) => unknown;
|
||||||
|
|
||||||
|
type MethodOf<T, K extends keyof T> = T[K] extends GenericCallback ? T[K] : never;
|
||||||
|
|
||||||
|
const debounce = <T extends GenericCallback>(fn: T, delay: number) => {
|
||||||
|
let timerId = 0;
|
||||||
|
|
||||||
|
return function (this: unknown, ...args: unknown[]) {
|
||||||
|
window.clearTimeout(timerId);
|
||||||
|
timerId = window.setTimeout(() => {
|
||||||
|
fn.call(this, ...args);
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const decorate = <T, M extends keyof T>(
|
||||||
|
proto: T,
|
||||||
|
method: M,
|
||||||
|
decorateFn: (this: unknown, superFn: T[M], ...args: unknown[]) => unknown
|
||||||
|
) => {
|
||||||
|
const superFn = proto[method] as MethodOf<T, M>;
|
||||||
|
|
||||||
|
proto[method] = function (this: unknown, ...args: unknown[]) {
|
||||||
|
superFn.call(this, ...args);
|
||||||
|
decorateFn.call(this, superFn, ...args);
|
||||||
|
} as MethodOf<T, M>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSupported = 'onscrollend' in window;
|
||||||
|
|
||||||
|
if (!isSupported) {
|
||||||
|
const pointers = new Set();
|
||||||
|
const scrollHandlers = new WeakMap<EventTarget, EventListenerOrEventListenerObject>();
|
||||||
|
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
pointers.add(event.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (event: PointerEvent) => {
|
||||||
|
pointers.delete(event.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('pointerdown', handlePointerDown);
|
||||||
|
document.addEventListener('pointerup', handlePointerUp);
|
||||||
|
|
||||||
|
decorate(EventTarget.prototype, 'addEventListener', function (this: EventTarget, addEventListener, type) {
|
||||||
|
if (type !== 'scroll') return;
|
||||||
|
|
||||||
|
const handleScrollEnd = debounce(() => {
|
||||||
|
if (!pointers.size) {
|
||||||
|
// If no pointer is active in the scroll area then the scroll has ended
|
||||||
|
this.dispatchEvent(new Event('scrollend'));
|
||||||
|
} else {
|
||||||
|
// otherwise let's wait a bit more
|
||||||
|
handleScrollEnd();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
addEventListener.call(this, 'scroll', handleScrollEnd, { passive: true });
|
||||||
|
scrollHandlers.set(this, handleScrollEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
decorate(EventTarget.prototype, 'removeEventListener', function (this: EventTarget, removeEventListener, type) {
|
||||||
|
if (type !== 'scroll') return;
|
||||||
|
|
||||||
|
const scrollHandler = scrollHandlers.get(this);
|
||||||
|
if (scrollHandler) {
|
||||||
|
removeEventListener.call(this, 'scroll', scrollHandler, { passive: true } as unknown as EventListenerOptions);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
Ładowanie…
Reference in New Issue