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
*
* ```
*/
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();
}
}