import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Carousel } from './carousel'; import { CAROUSEL_CHANGE_EVENT, type CarouselChangeEvent } from './carousel.types'; /** * Embla measures real layout, which jsdom does not provide. We mock the engine * with a faithful in-memory snap model (clamp/loop) so the controller's own * responsibilities — ARIA, dots, events, control disabling, autoplay — are * exercised without depending on pixel layout. Drag/swipe physics is Embla's * job and is covered in the browser (Storybook), not here. */ vi.mock('embla-carousel', () => { type Listener = () => void; function EmblaCarousel( viewport: HTMLElement, options: { loop?: boolean; startIndex?: number } = {}, ) { const count = viewport.querySelectorAll('[data-c42-carousel-slide]').length; const loop = options.loop ?? false; const listeners = new Map(); const clamp = (i: number): number => Math.max(0, Math.min(i, count - 1)); let selected = clamp(options.startIndex ?? 0); const emit = (name: string): void => { (listeners.get(name) ?? []).forEach((fn) => fn()); }; const set = (i: number): void => { const next = loop ? ((i % count) + count) % count : clamp(i); if (next === selected) { return; } selected = next; emit('select'); }; const api = { scrollTo: (i: number): void => set(i), scrollNext: (): void => set(loop && selected === count - 1 ? 0 : selected + 1), scrollPrev: (): void => set(loop && selected === 0 ? count - 1 : selected - 1), selectedScrollSnap: (): number => selected, scrollSnapList: (): number[] => Array.from({ length: count }, (_, i) => i), canScrollNext: (): boolean => loop || selected < count - 1, canScrollPrev: (): boolean => loop || selected > 0, on: (name: string, fn: Listener) => { const arr = listeners.get(name) ?? []; arr.push(fn); listeners.set(name, arr); return api; }, off: (name: string, fn: Listener) => { listeners.set(name, (listeners.get(name) ?? []).filter((f) => f !== fn)); return api; }, destroy: (): void => listeners.clear(), reInit: (): void => undefined, }; return api; } return { default: EmblaCarousel }; }); const MARKUP = `
One
Two
Three
`; function setup(): HTMLElement { document.body.innerHTML = MARKUP; return document.body.firstElementChild as HTMLElement; } function slides(root: HTMLElement): HTMLElement[] { return Array.from(root.querySelectorAll('[data-c42-carousel-slide]')); } function dots(root: HTMLElement): HTMLElement[] { return Array.from(root.querySelectorAll('[data-c42-carousel-dot]')); } function prev(root: HTMLElement): HTMLElement { return root.querySelector('[data-c42-carousel-prev]')!; } function next(root: HTMLElement): HTMLElement { return root.querySelector('[data-c42-carousel-next]')!; } describe('Carousel', () => { beforeEach(() => { document.body.innerHTML = ''; }); it('wires carousel ARIA and activates the first slide by default', () => { const root = setup(); new Carousel(root); expect(root.getAttribute('role')).toBe('region'); expect(root.getAttribute('aria-roledescription')).toBe('carousel'); const [first, second] = slides(root); expect(first.dataset.active).toBe('true'); expect(first.getAttribute('aria-roledescription')).toBe('slide'); expect(first.getAttribute('aria-label')).toBe('1 of 3'); expect(second.dataset.active).toBe('false'); expect(root.dataset.activeIndex).toBe('0'); }); it('throws when the track is missing', () => { document.body.innerHTML = '
'; const root = document.body.firstElementChild as HTMLElement; expect(() => new Carousel(root)).toThrow(/track/); }); it('throws when there are no slides', () => { document.body.innerHTML = '
'; const root = document.body.firstElementChild as HTMLElement; expect(() => new Carousel(root)).toThrow(/slide/); }); it('honors defaultIndex (clamped)', () => { const root = setup(); const carousel = new Carousel(root, { defaultIndex: 9 }); expect(carousel.index).toBe(2); }); it('generates one dot per slide and marks the active one', () => { const root = setup(); new Carousel(root); const generated = dots(root); expect(generated).toHaveLength(3); expect(generated[0].dataset.active).toBe('true'); expect(generated[0].getAttribute('aria-current')).toBe('true'); }); it('advances and goes back with next/prev', () => { const root = setup(); const carousel = new Carousel(root); next(root).click(); expect(carousel.index).toBe(1); expect(slides(root)[1].dataset.active).toBe('true'); prev(root).click(); expect(carousel.index).toBe(0); }); it('clamps at the ends and disables controls without loop', () => { const root = setup(); const carousel = new Carousel(root); expect(prev(root).hasAttribute('disabled')).toBe(true); prev(root).click(); expect(carousel.index).toBe(0); carousel.goTo(2); expect(next(root).hasAttribute('disabled')).toBe(true); next(root).click(); expect(carousel.index).toBe(2); }); it('wraps around when loop is enabled', () => { const root = setup(); const carousel = new Carousel(root, { loop: true }); prev(root).click(); expect(carousel.index).toBe(2); next(root).click(); expect(carousel.index).toBe(0); expect(prev(root).hasAttribute('disabled')).toBe(false); }); it('jumps to a slide when its dot is clicked', () => { const root = setup(); const carousel = new Carousel(root); dots(root)[2].click(); expect(carousel.index).toBe(2); }); it('navigates with arrow keys, Home and End', () => { const root = setup(); const carousel = new Carousel(root); root.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); expect(carousel.index).toBe(1); root.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })); expect(carousel.index).toBe(2); root.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true })); expect(carousel.index).toBe(0); root.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); expect(carousel.index).toBe(0); }); it('emits change events with index and previousIndex', () => { const root = setup(); const carousel = new Carousel(root); const spy = vi.fn(); carousel.on(CAROUSEL_CHANGE_EVENT, spy); next(root).click(); expect(spy).toHaveBeenCalledOnce(); expect(spy.mock.calls[0][0].detail).toEqual({ index: 1, previousIndex: 0 }); }); it('removes listeners on destroy', () => { const root = setup(); const carousel = new Carousel(root); carousel.destroy(); next(root).click(); expect(carousel.index).toBe(0); }); describe('autoplay', () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); it('auto-advances on the interval and stops on destroy', () => { const root = setup(); const carousel = new Carousel(root, { autoplay: true, autoplayInterval: 1000, loop: true }); vi.advanceTimersByTime(1000); expect(carousel.index).toBe(1); vi.advanceTimersByTime(2000); expect(carousel.index).toBe(0); // 2 -> wrap to 0 carousel.destroy(); vi.advanceTimersByTime(5000); expect(carousel.index).toBe(0); }); it('pauses on pointer enter and resumes on leave', () => { const root = setup(); const carousel = new Carousel(root, { autoplay: true, autoplayInterval: 1000 }); root.dispatchEvent(new Event('pointerenter')); vi.advanceTimersByTime(3000); expect(carousel.index).toBe(0); root.dispatchEvent(new Event('pointerleave')); vi.advanceTimersByTime(1000); expect(carousel.index).toBe(1); }); }); });