feat: carousel core (count, navigation, scrollToIndex)

This commit is contained in:
Ilia Mashkov
2026-07-01 07:53:46 +03:00
parent 067f0d472b
commit 79e59f8a84
3 changed files with 130 additions and 1 deletions
+72 -1
View File
@@ -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();
},
};
}
+57
View File
@@ -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();
});
+1
View File
@@ -5,6 +5,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"strict": true, "strict": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"noEmit": true, "noEmit": true,
"skipLibCheck": true "skipLibCheck": true