feat: carousel core (count, navigation, scrollToIndex)
This commit is contained in:
+72
-1
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user