export type CarouselEvent = 'change'; export type Carousel = { next(): void; prev(): void; scrollToIndex(i: number): void; readonly index: number; readonly count: number; on(evt: CarouselEvent, cb: (index: number) => void): () => void; destroy(): void; }; /** * Wrap a scroll-snap track element and return a carousel controller. * @param track - the overflow-x scroll container whose children are slides */ export function createCarousel(track: HTMLElement): Carousel { const slides = () => Array.from(track.children) as HTMLElement[]; let index = 0; const listeners = new Set<(i: number) => void>(); const clamp = (i: number) => Math.max(0, Math.min(i, slides().length - 1)); function scrollToIndex(i: number) { const target = slides()[clamp(i)]; if (target) { track.scrollTo({ left: target.offsetLeft, behavior: 'smooth' }); } } // index tracking: the most-intersecting slide is current. Picking max ratio // (not the first intersecting) avoids a transient wrong index mid-swipe when // two slides cross the threshold in one callback. const io = new IntersectionObserver( (entries) => { let best: IntersectionObserverEntry | undefined; for (const e of entries) { if (e.isIntersecting && (!best || e.intersectionRatio > best.intersectionRatio)) { best = e; } } if (!best) return; const i = slides().indexOf(best.target as HTMLElement); if (i !== -1 && i !== index) { index = i; for (const cb of listeners) cb(index); } }, { root: track, threshold: 0.6 }, ); for (const s of slides()) io.observe(s); return { next: () => scrollToIndex(index + 1), prev: () => scrollToIndex(index - 1), scrollToIndex, get index() { return index; }, get count() { return slides().length; }, on(_evt, cb) { listeners.add(cb); return () => listeners.delete(cb); }, destroy() { io.disconnect(); listeners.clear(); }, }; }