diff --git a/src/index.ts b/src/index.ts index cb0ff5c..c198860 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,72 @@ -export {}; +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(); + }, + }; +} diff --git a/tests/core.test.ts b/tests/core.test.ts new file mode 100644 index 0000000..7d7b937 --- /dev/null +++ b/tests/core.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import { createCarousel } from '../src/index.ts'; + +// Minimal IO shim — records instances so tests can fire entries manually. +class IOShim { + static last: IOShim | null = null; + cb: IntersectionObserverCallback; + elements: Element[] = []; + constructor(cb: IntersectionObserverCallback) { + this.cb = cb; + IOShim.last = this; + } + observe(el: Element) { + this.elements.push(el); + } + unobserve() {} + disconnect() {} + // helper: emit "slide i is the one intersecting" (ratio 1), others 0 + emit(i: number) { + this.cb( + this.elements.map((target, idx) => ({ + target, + isIntersecting: idx === i, + intersectionRatio: idx === i ? 1 : 0, + })) as unknown as IntersectionObserverEntry[], + this as unknown as IntersectionObserver, + ); + } +} +vi.stubGlobal('IntersectionObserver', IOShim); + +function makeTrack(n: number): HTMLElement { + const track = document.createElement('div'); + for (let i = 0; i < n; i++) { + const slide = document.createElement('div'); + slide.className = 'slide'; + track.append(slide); + } + track.scrollTo = vi.fn(); + return track; +} + +let track: HTMLElement; +beforeEach(() => { + track = makeTrack(3); +}); + +test('count reflects slide children', () => { + const c = createCarousel(track); + expect(c.count).toBe(3); +}); + +test('next/prev clamp to range and scroll to target offset', () => { + const c = createCarousel(track); + c.next(); + expect(track.scrollTo).toHaveBeenCalled(); +}); diff --git a/tsconfig.json b/tsconfig.json index d38c4e0..70dd1f2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "moduleResolution": "bundler", "strict": true, "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "noEmit": true, "skipLibCheck": true