import { uid } from '../shared/dom'; import { createEmbla, type EmblaCarouselType, type EmblaOptionsType } from '../shared/embla'; import { CAROUSEL_CHANGE_EVENT, type CarouselChangeDetail, type CarouselOptions, } from './carousel.types'; const SELECTORS = { viewport: '[data-c42-carousel-viewport]', track: '[data-c42-carousel-track]', slide: '[data-c42-carousel-slide]', prev: '[data-c42-carousel-prev]', next: '[data-c42-carousel-next]', dots: '[data-c42-carousel-dots]', } as const; /** * Headless carousel controller. The scroll/snap engine (drag physics, momentum, * snapping, loop wrapping, resize handling and axis) is delegated to * [Embla Carousel](https://www.embla-carousel.com/); this controller stays * responsible for the headless contract: ARIA wiring, auto-generated dot * indicators, prev/next controls, keyboard navigation, optional autoplay and * typed `carousel:change` events. It reflects the active index on * `data-active` (slides/dots) and `data-active-index` (root) — it applies no * visual styling itself. * * Markup: * ```html *
*
*
*
One
*
Two
*
*
* * *
*
* ``` */ export class Carousel { private readonly root: HTMLElement; private readonly slides: HTMLElement[]; private readonly prevBtn: HTMLElement | null; private readonly nextBtn: HTMLElement | null; private readonly dotsContainer: HTMLElement | null; private dots: HTMLElement[] = []; private readonly embla: EmblaCarouselType; private readonly autoplayEnabled: boolean; private readonly autoplayInterval: number; private readonly pauseOnHover: boolean; private current = 0; private timer: ReturnType | null = null; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: CarouselOptions = {}) { const track = root.querySelector(SELECTORS.track); if (!track) { throw new Error('[42/carousel] Needs a [data-c42-carousel-track] element.'); } const slides = Array.from(track.querySelectorAll(SELECTORS.slide)); if (slides.length === 0) { throw new Error('[42/carousel] Needs at least one [data-c42-carousel-slide].'); } const viewport = root.querySelector(SELECTORS.viewport) ?? track.parentElement ?? track; this.root = root; this.slides = slides; this.prevBtn = root.querySelector(SELECTORS.prev); this.nextBtn = root.querySelector(SELECTORS.next); this.dotsContainer = root.querySelector(SELECTORS.dots); this.autoplayEnabled = options.autoplay ?? false; this.autoplayInterval = options.autoplayInterval ?? 4000; this.pauseOnHover = options.pauseOnHover ?? true; const emblaOptions: EmblaOptionsType = { loop: options.loop ?? false, axis: options.axis ?? 'x', align: options.align ?? 'center', slidesToScroll: options.slidesToScroll ?? 1, dragFree: options.dragFree ?? false, watchDrag: options.swipe ?? true, startIndex: options.defaultIndex ?? 0, }; this.embla = createEmbla(viewport, emblaOptions); this.current = this.embla.selectedScrollSnap(); this.init(); } private init(): void { this.root.setAttribute('role', 'region'); this.root.setAttribute('aria-roledescription', 'carousel'); this.slides.forEach((slide, index) => { slide.setAttribute('role', 'group'); slide.setAttribute('aria-roledescription', 'slide'); slide.setAttribute('aria-label', `${index + 1} of ${this.slides.length}`); }); if (this.prevBtn) { if (!this.prevBtn.hasAttribute('aria-label')) { this.prevBtn.setAttribute('aria-label', 'Previous slide'); } const onPrev = (): void => this.prev(); this.prevBtn.addEventListener('click', onPrev); this.cleanups.push(() => this.prevBtn?.removeEventListener('click', onPrev)); } if (this.nextBtn) { if (!this.nextBtn.hasAttribute('aria-label')) { this.nextBtn.setAttribute('aria-label', 'Next slide'); } const onNext = (): void => this.next(); this.nextBtn.addEventListener('click', onNext); this.cleanups.push(() => this.nextBtn?.removeEventListener('click', onNext)); } this.buildDots(); const onKeydown = (event: KeyboardEvent): void => this.onKeydown(event); this.root.addEventListener('keydown', onKeydown); this.cleanups.push(() => this.root.removeEventListener('keydown', onKeydown)); // Embla drives the selected snap; mirror it into our logical state. const onSelect = (): void => this.syncFromEngine(); this.embla.on('select', onSelect); this.cleanups.push(() => this.embla.off('select', onSelect)); if (this.autoplayEnabled) { this.play(); if (this.pauseOnHover) { this.bindPause(); } } this.render(); } private buildDots(): void { if (!this.dotsContainer) { return; } this.dotsContainer.setAttribute('role', 'tablist'); this.dotsContainer.setAttribute('aria-label', 'Choose slide'); // Use existing dot buttons if provided, otherwise generate one per slide. let dots = Array.from( this.dotsContainer.querySelectorAll('[data-c42-carousel-dot]'), ); if (dots.length === 0) { dots = this.slides.map((_, index) => { const dot = document.createElement('button'); dot.type = 'button'; dot.dataset.c42CarouselDot = ''; dot.setAttribute('aria-label', `Go to slide ${index + 1}`); this.dotsContainer!.appendChild(dot); return dot; }); } this.dots = dots; this.dots.forEach((dot, index) => { if (!dot.id) { dot.id = uid('carousel-dot'); } dot.setAttribute('role', 'tab'); const onClick = (): void => this.goTo(index); dot.addEventListener('click', onClick); this.cleanups.push(() => dot.removeEventListener('click', onClick)); }); } private bindPause(): void { const pause = (): void => this.stopTimer(); const resume = (): void => { if (this.autoplayEnabled) { this.startTimer(); } }; this.root.addEventListener('pointerenter', pause); this.root.addEventListener('pointerleave', resume); this.root.addEventListener('focusin', pause); this.root.addEventListener('focusout', resume); this.cleanups.push( () => this.root.removeEventListener('pointerenter', pause), () => this.root.removeEventListener('pointerleave', resume), () => this.root.removeEventListener('focusin', pause), () => this.root.removeEventListener('focusout', resume), ); } private onKeydown(event: KeyboardEvent): void { switch (event.key) { case 'ArrowLeft': event.preventDefault(); this.prev(); break; case 'ArrowRight': event.preventDefault(); this.next(); break; case 'Home': event.preventDefault(); this.goTo(0); break; case 'End': event.preventDefault(); this.goTo(this.slides.length - 1); break; default: break; } } private startTimer(): void { this.stopTimer(); this.timer = setInterval(() => this.embla.scrollNext(), this.autoplayInterval); } private stopTimer(): void { if (this.timer !== null) { clearInterval(this.timer); this.timer = null; } } /** Pull the selected index from Embla, update DOM state and emit change. */ private syncFromEngine(): void { const previousIndex = this.current; const index = this.embla.selectedScrollSnap(); if (index === previousIndex) { this.render(); return; } this.current = index; this.render(); const detail: CarouselChangeDetail = { index, previousIndex }; this.root.dispatchEvent(new CustomEvent(CAROUSEL_CHANGE_EVENT, { detail, bubbles: true })); } private render(): void { this.root.dataset.activeIndex = String(this.current); this.slides.forEach((slide, index) => { const active = index === this.current; slide.dataset.active = String(active); slide.setAttribute('aria-hidden', String(!active)); }); this.dots.forEach((dot, index) => { const active = index === this.current; dot.dataset.active = String(active); dot.setAttribute('aria-selected', String(active)); if (active) { dot.setAttribute('aria-current', 'true'); } else { dot.removeAttribute('aria-current'); } }); this.toggleControl(this.prevBtn, !this.embla.canScrollPrev()); this.toggleControl(this.nextBtn, !this.embla.canScrollNext()); } private toggleControl(control: HTMLElement | null, disabled: boolean): void { if (!control) { return; } control.toggleAttribute('disabled', disabled); control.dataset.disabled = String(disabled); } /** Go to a specific slide (clamped by the engine). */ goTo(index: number): void { this.embla.scrollTo(index); } /** Advance one slide (wraps when `loop` is enabled). */ next(): void { this.embla.scrollNext(); } /** Go back one slide (wraps when `loop` is enabled). */ prev(): void { this.embla.scrollPrev(); } /** Start autoplay. */ play(): void { this.startTimer(); } /** Stop autoplay. */ pause(): void { this.stopTimer(); } get index(): number { return this.current; } get length(): number { return this.slides.length; } /** Subscribe to a DOM event on the root element. Returns an unsubscribe fn. */ on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.root.addEventListener(event, listener); const off = (): void => this.root.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.stopTimer(); this.cleanups.forEach((fn) => fn()); this.cleanups = []; this.embla.destroy(); } }