355 lines
11 KiB
TypeScript
355 lines
11 KiB
TypeScript
import EmblaCarousel, { type EmblaCarouselType } from 'embla-carousel';
|
|
import {
|
|
beforeAll,
|
|
beforeEach,
|
|
describe,
|
|
expect,
|
|
it,
|
|
vi,
|
|
type MockedObject,
|
|
} from 'vitest';
|
|
|
|
import { CarouselController } from '../../../src/utils/embla/carousel-controller';
|
|
import {
|
|
callMutationHandler,
|
|
createParent,
|
|
createSlot,
|
|
createSlotHost,
|
|
MutationObserverMock,
|
|
} from '../../test-utils';
|
|
import {
|
|
callEmblaHandler,
|
|
createEmblaApiInstance,
|
|
createTestSlideNodes,
|
|
} from './test-utils';
|
|
|
|
vi.mock('embla-carousel', () => ({
|
|
default: vi.fn().mockImplementation(() => {
|
|
return createEmblaApiInstance();
|
|
}),
|
|
}));
|
|
|
|
// Get the nth most recently constructed EmblaAPI instance.
|
|
const getEmblaApi = (n = 0): MockedObject<EmblaCarouselType> | null => {
|
|
const constructions = vi.mocked(EmblaCarousel).mock.results;
|
|
const mostRecentResult = constructions[constructions.length - 1 - n] ?? null;
|
|
if (mostRecentResult && mostRecentResult.type === 'return') {
|
|
return vi.mocked(mostRecentResult.value);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const createRoot = (): HTMLElement => {
|
|
return document.createElement('div');
|
|
};
|
|
|
|
// @vitest-environment jsdom
|
|
describe('CarouselController', () => {
|
|
beforeAll(() => {
|
|
vi.stubGlobal('MutationObserver', MutationObserverMock);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('should construct', () => {
|
|
const children = createTestSlideNodes();
|
|
const parent = createParent({ children: children });
|
|
const carousel = new CarouselController(createRoot(), parent);
|
|
expect(carousel).toBeTruthy();
|
|
});
|
|
|
|
it('should construct with slot parent', () => {
|
|
const slot = createSlot();
|
|
const host = createSlotHost({ slot: slot, children: createTestSlideNodes() });
|
|
const carousel = new CarouselController(host, slot);
|
|
expect(carousel).toBeTruthy();
|
|
});
|
|
|
|
it('should destroy', () => {
|
|
const children = createTestSlideNodes();
|
|
const parent = createParent({ children: children });
|
|
const carousel = new CarouselController(createRoot(), parent);
|
|
|
|
carousel.destroy();
|
|
|
|
expect(getEmblaApi()?.destroy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should destroy with slot', () => {
|
|
const slot = createSlot();
|
|
const host = createSlotHost({ slot: slot, children: createTestSlideNodes() });
|
|
const carousel = new CarouselController(host, slot);
|
|
|
|
carousel.destroy();
|
|
|
|
expect(getEmblaApi()?.destroy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should get slide by index', () => {
|
|
const children = createTestSlideNodes();
|
|
const parent = createParent({ children: children });
|
|
const carousel = new CarouselController(createRoot(), parent);
|
|
|
|
getEmblaApi()?.slideNodes.mockReturnValue(children);
|
|
expect(carousel.getSlide(2)).toBe(children[2]);
|
|
});
|
|
|
|
it('should get slide by index when index is invalid', () => {
|
|
const children = createTestSlideNodes();
|
|
const parent = createParent({ children: children });
|
|
const carousel = new CarouselController(createRoot(), parent);
|
|
|
|
expect(carousel.getSlide(1000)).toBeNull();
|
|
});
|
|
|
|
it('should get selected slide', () => {
|
|
const children = createTestSlideNodes();
|
|
const parent = createParent({ children: children });
|
|
const carousel = new CarouselController(createRoot(), parent);
|
|
|
|
getEmblaApi()?.slideNodes.mockReturnValue(children);
|
|
getEmblaApi()?.selectedScrollSnap.mockReturnValue(3);
|
|
expect(carousel.getSelectedIndex()).toBe(3);
|
|
expect(carousel.getSelectedSlide()).toBe(children[3]);
|
|
});
|
|
|
|
it('should select given slide', () => {
|
|
const children = createTestSlideNodes();
|
|
const parent = createParent({ children: children });
|
|
|
|
const carousel = new CarouselController(createRoot(), parent);
|
|
|
|
carousel.selectSlide(4);
|
|
|
|
expect(getEmblaApi()?.scrollTo).toHaveBeenCalledWith(4, false);
|
|
});
|
|
|
|
it('should not select non-existent slide', () => {
|
|
const children = createTestSlideNodes({ n: 10 });
|
|
const parent = createParent({ children: children });
|
|
|
|
const carousel = new CarouselController(createRoot(), parent);
|
|
|
|
carousel.selectSlide(11);
|
|
|
|
// Should not call scrollTo because index is out of bounds.
|
|
expect(getEmblaApi()?.scrollTo).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should dispatch select event', () => {
|
|
const children = createTestSlideNodes();
|
|
const parent = createParent({ children: children });
|
|
new CarouselController(createRoot(), parent);
|
|
|
|
const selectHandler = vi.fn();
|
|
parent.addEventListener('advanced-camera-card:carousel:select', selectHandler);
|
|
|
|
getEmblaApi()?.selectedScrollSnap.mockReturnValue(6);
|
|
getEmblaApi()?.slideNodes.mockReturnValue(children);
|
|
callEmblaHandler(getEmblaApi(), 'select');
|
|
|
|
expect(selectHandler).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
detail: {
|
|
index: 6,
|
|
element: children[6],
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should not dispatch anything with an invalid scroll snap', () => {
|
|
const children = createTestSlideNodes();
|
|
const parent = createParent({ children: children });
|
|
new CarouselController(createRoot(), parent);
|
|
|
|
const selectHandler = vi.fn();
|
|
parent.addEventListener('advanced-camera-card:carousel:select', selectHandler);
|
|
|
|
getEmblaApi()?.selectedScrollSnap.mockReturnValue(1000);
|
|
getEmblaApi()?.slideNodes.mockReturnValue(children);
|
|
callEmblaHandler(getEmblaApi(), 'init');
|
|
callEmblaHandler(getEmblaApi(), 'select');
|
|
callEmblaHandler(getEmblaApi(), 'settle');
|
|
|
|
expect(selectHandler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should honor creation options', () => {
|
|
const children = createTestSlideNodes({ n: 1 });
|
|
const root = createRoot();
|
|
const parent = createParent({ children: children });
|
|
|
|
new CarouselController(root, parent, {
|
|
direction: 'vertical',
|
|
transitionEffect: 'none',
|
|
startIndex: 7,
|
|
dragFree: true,
|
|
loop: true,
|
|
dragEnabled: false,
|
|
textDirection: 'rtl',
|
|
});
|
|
|
|
expect(EmblaCarousel).toHaveBeenCalledWith(
|
|
root,
|
|
{
|
|
slides: children,
|
|
axis: 'y',
|
|
duration: 20,
|
|
startIndex: 7,
|
|
dragFree: true,
|
|
loop: true,
|
|
containScroll: 'trimSnaps',
|
|
watchSlides: false,
|
|
watchResize: true,
|
|
watchDrag: expect.any(Function),
|
|
direction: 'rtl',
|
|
},
|
|
[],
|
|
);
|
|
});
|
|
|
|
it('should pass a watchDrag predicate reflecting the current drag state', () => {
|
|
const children = createTestSlideNodes();
|
|
const root = createRoot();
|
|
const parent = createParent({ children: children });
|
|
|
|
const carousel = new CarouselController(root, parent, { dragEnabled: true });
|
|
|
|
const emblaOptions = vi.mocked(EmblaCarousel).mock.calls[0][1] as {
|
|
watchDrag: () => boolean;
|
|
};
|
|
expect(emblaOptions.watchDrag()).toBe(true);
|
|
|
|
carousel.setDragEnabled(false);
|
|
expect(emblaOptions.watchDrag()).toBe(false);
|
|
|
|
carousel.setDragEnabled(true);
|
|
expect(emblaOptions.watchDrag()).toBe(true);
|
|
});
|
|
|
|
it('should toggle drag without rebuilding the carousel', () => {
|
|
const children = createTestSlideNodes();
|
|
const parent = createParent({ children: children });
|
|
const carousel = new CarouselController(createRoot(), parent, {
|
|
dragEnabled: true,
|
|
});
|
|
|
|
const emblaApi = getEmblaApi();
|
|
expect(emblaApi).toBeTruthy();
|
|
|
|
carousel.setDragEnabled(false);
|
|
expect(emblaApi?.reInit).not.toHaveBeenCalled();
|
|
expect(emblaApi?.destroy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should include wheel plugin when slides > 1', () => {
|
|
const children = createTestSlideNodes();
|
|
const root = createRoot();
|
|
const parent = createParent({ children: children });
|
|
new CarouselController(root, parent);
|
|
|
|
expect(EmblaCarousel).toHaveBeenCalledWith(
|
|
root,
|
|
expect.anything(),
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
name: 'wheelGestures',
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('should not include wheel plugin when wheelScrolling is false', () => {
|
|
const children = createTestSlideNodes();
|
|
const root = createRoot();
|
|
const parent = createParent({ children: children });
|
|
new CarouselController(root, parent, { wheelScrolling: false });
|
|
|
|
// Verify WheelGesturesPlugin is NOT present
|
|
expect(EmblaCarousel).toHaveBeenCalledWith(
|
|
root,
|
|
expect.anything(),
|
|
expect.not.arrayContaining([
|
|
expect.objectContaining({
|
|
name: 'wheelGestures',
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('should reinit carousel when children are added', () => {
|
|
const children = createTestSlideNodes();
|
|
const root = createRoot();
|
|
const parent = createParent({ children: children });
|
|
new CarouselController(root, parent);
|
|
|
|
expect(EmblaCarousel).toHaveBeenCalledTimes(1);
|
|
|
|
const originalEmblaApi = getEmblaApi();
|
|
expect(originalEmblaApi).toBeTruthy();
|
|
|
|
originalEmblaApi?.slideNodes.mockReturnValue(children);
|
|
const newChild = document.createElement('div');
|
|
parent.appendChild(newChild);
|
|
callMutationHandler();
|
|
|
|
// Should call reInit instead of destroy/recreate
|
|
expect(originalEmblaApi?.reInit).toHaveBeenCalledWith({
|
|
slides: [...children, newChild],
|
|
});
|
|
|
|
// Should still be same carousel instance (no new creation)
|
|
expect(EmblaCarousel).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should not recreate carousel when children have not changed', () => {
|
|
const children = createTestSlideNodes();
|
|
const root = createRoot();
|
|
const parent = createParent({ children: children });
|
|
new CarouselController(root, parent);
|
|
|
|
expect(EmblaCarousel).toHaveBeenCalledTimes(1);
|
|
|
|
const originalEmblaApi = getEmblaApi();
|
|
expect(originalEmblaApi).toBeTruthy();
|
|
|
|
originalEmblaApi?.slideNodes.mockReturnValue(children);
|
|
callMutationHandler();
|
|
|
|
expect(originalEmblaApi?.destroy).not.toHaveBeenCalled();
|
|
expect(getEmblaApi()).toBe(originalEmblaApi);
|
|
|
|
expect(EmblaCarousel).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should reinit carousel when children are added to slot', () => {
|
|
const children = createTestSlideNodes();
|
|
const slot = createSlot();
|
|
const host = createSlotHost({ slot: slot, children: children });
|
|
|
|
new CarouselController(host, slot);
|
|
|
|
expect(EmblaCarousel).toHaveBeenCalledTimes(1);
|
|
|
|
const originalEmblaApi = getEmblaApi();
|
|
expect(originalEmblaApi).toBeTruthy();
|
|
|
|
originalEmblaApi?.slideNodes.mockReturnValue(children);
|
|
|
|
const newChild = document.createElement('div');
|
|
host.appendChild(newChild);
|
|
slot.dispatchEvent(new Event('slotchange'));
|
|
|
|
// Should call reInit instead of destroy/recreate
|
|
expect(originalEmblaApi?.reInit).toHaveBeenCalledWith({
|
|
slides: [...children, newChild],
|
|
});
|
|
|
|
// Should still be same carousel instance (no new creation)
|
|
expect(EmblaCarousel).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|