Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e273cad04b |
@@ -5,6 +5,11 @@ at each `v*` release tag.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.2.0] - 2026-07-02
|
||||
|
||||
- Core: `atStart`/`atEnd`/`overflow` getters; `change` now fires on any state change (index, count, overflow), consumers re-read getters.
|
||||
- Core: ResizeObserver drives `overflow`; MutationObserver observes/unobserves slides so count/index/edge flags stay correct across add/remove without recreating the carousel.
|
||||
|
||||
## [0.1.0] - 2026-07-01
|
||||
|
||||
- Initial core (`createCarousel`), `dots()` fallback, `autoplay()`, and `carousel.css`.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ilia/carousel",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"*.css"
|
||||
|
||||
+50
-1
@@ -6,6 +6,9 @@ export type Carousel = {
|
||||
scrollToIndex(i: number): void;
|
||||
readonly index: number;
|
||||
readonly count: number;
|
||||
readonly atStart: boolean;
|
||||
readonly atEnd: boolean;
|
||||
readonly overflow: boolean;
|
||||
on(evt: CarouselEvent, cb: (index: number) => void): () => void;
|
||||
destroy(): void;
|
||||
};
|
||||
@@ -18,6 +21,11 @@ export function createCarousel(track: HTMLElement): Carousel {
|
||||
const slides = () => Array.from(track.children) as HTMLElement[];
|
||||
let index = 0;
|
||||
const listeners = new Set<(i: number) => void>();
|
||||
// One 'change' event for any state change (index/count/overflow); consumers
|
||||
// re-read the getters. Keeps the surface at a single event type.
|
||||
const notify = () => {
|
||||
for (const cb of listeners) cb(index);
|
||||
};
|
||||
|
||||
const clamp = (i: number) => Math.max(0, Math.min(i, slides().length - 1));
|
||||
|
||||
@@ -43,13 +51,43 @@ export function createCarousel(track: HTMLElement): Carousel {
|
||||
const i = slides().indexOf(best.target as HTMLElement);
|
||||
if (i !== -1 && i !== index) {
|
||||
index = i;
|
||||
for (const cb of listeners) cb(index);
|
||||
notify();
|
||||
}
|
||||
},
|
||||
{ root: track, threshold: 0.6 },
|
||||
);
|
||||
for (const s of slides()) io.observe(s);
|
||||
|
||||
// overflow: whether the track actually scrolls. Changes on resize (track box)
|
||||
// *and* on add/remove (scrollWidth), so both observers recompute it.
|
||||
let overflow = false;
|
||||
const syncOverflow = () => {
|
||||
const o = track.scrollWidth > track.clientWidth;
|
||||
if (o !== overflow) {
|
||||
overflow = o;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// observe/unobserve slides as they're added/removed so count, index and edge
|
||||
// flags stay correct without recreating the carousel.
|
||||
const mo = new MutationObserver((records) => {
|
||||
for (const r of records) {
|
||||
for (const n of r.addedNodes) if (n instanceof HTMLElement) io.observe(n);
|
||||
for (const n of r.removedNodes) if (n instanceof HTMLElement) io.unobserve(n);
|
||||
}
|
||||
index = clamp(index);
|
||||
syncOverflow();
|
||||
notify();
|
||||
});
|
||||
mo.observe(track, { childList: true });
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (syncOverflow()) notify();
|
||||
});
|
||||
ro.observe(track);
|
||||
|
||||
return {
|
||||
next: () => scrollToIndex(index + 1),
|
||||
prev: () => scrollToIndex(index - 1),
|
||||
@@ -60,12 +98,23 @@ export function createCarousel(track: HTMLElement): Carousel {
|
||||
get count() {
|
||||
return slides().length;
|
||||
},
|
||||
get atStart() {
|
||||
return index === 0;
|
||||
},
|
||||
get atEnd() {
|
||||
return index >= slides().length - 1;
|
||||
},
|
||||
get overflow() {
|
||||
return overflow;
|
||||
},
|
||||
on(_evt, cb) {
|
||||
listeners.add(cb);
|
||||
return () => listeners.delete(cb);
|
||||
},
|
||||
destroy() {
|
||||
io.disconnect();
|
||||
mo.disconnect();
|
||||
ro.disconnect();
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -29,6 +29,23 @@ class IOShim {
|
||||
}
|
||||
vi.stubGlobal('IntersectionObserver', IOShim);
|
||||
|
||||
// jsdom has no ResizeObserver; record instance so tests can trigger it.
|
||||
class ROShim {
|
||||
static last: ROShim | null = null;
|
||||
cb: ResizeObserverCallback;
|
||||
constructor(cb: ResizeObserverCallback) {
|
||||
this.cb = cb;
|
||||
ROShim.last = this;
|
||||
}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
fire() {
|
||||
this.cb([] as unknown as ResizeObserverEntry[], this as unknown as ResizeObserver);
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', ROShim);
|
||||
|
||||
function makeTrack(n: number): HTMLElement {
|
||||
const track = document.createElement('div');
|
||||
for (let i = 0; i < n; i++) {
|
||||
@@ -85,3 +102,76 @@ test('unsubscribe stops delivery; destroy disconnects observer', () => {
|
||||
c.destroy();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('atStart/atEnd track the current index', () => {
|
||||
const c = createCarousel(track);
|
||||
expect(c.atStart).toBe(true);
|
||||
expect(c.atEnd).toBe(false);
|
||||
(IOShim.last as IOShim).emit(2);
|
||||
expect(c.atStart).toBe(false);
|
||||
expect(c.atEnd).toBe(true);
|
||||
});
|
||||
|
||||
test('overflow flips on resize and fires change once per change', () => {
|
||||
const c = createCarousel(track);
|
||||
expect(c.overflow).toBe(false);
|
||||
const seen: number[] = [];
|
||||
c.on('change', (i) => seen.push(i));
|
||||
|
||||
Object.defineProperty(track, 'scrollWidth', { value: 100, configurable: true });
|
||||
Object.defineProperty(track, 'clientWidth', { value: 50, configurable: true });
|
||||
(ROShim.last as ROShim).fire();
|
||||
expect(c.overflow).toBe(true);
|
||||
expect(seen).toEqual([0]);
|
||||
|
||||
(ROShim.last as ROShim).fire(); // no change → no re-fire
|
||||
expect(seen).toEqual([0]);
|
||||
});
|
||||
|
||||
test('adding/removing slides updates count and fires change', async () => {
|
||||
const c = createCarousel(track);
|
||||
const io = IOShim.last as IOShim;
|
||||
const seen: number[] = [];
|
||||
c.on('change', (i) => seen.push(i));
|
||||
|
||||
const observe = vi.spyOn(io, 'observe');
|
||||
const unobserve = vi.spyOn(io, 'unobserve');
|
||||
|
||||
const extra = document.createElement('div');
|
||||
track.append(extra);
|
||||
await Promise.resolve(); // MutationObserver is async (microtask)
|
||||
expect(observe).toHaveBeenCalledWith(extra);
|
||||
|
||||
track.removeChild(extra);
|
||||
await Promise.resolve();
|
||||
expect(unobserve).toHaveBeenCalledWith(extra);
|
||||
expect(c.count).toBe(3);
|
||||
expect(seen.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('overflow recomputes on add/remove without a resize', async () => {
|
||||
Object.defineProperty(track, 'clientWidth', { value: 50, configurable: true });
|
||||
Object.defineProperty(track, 'scrollWidth', { value: 40, configurable: true });
|
||||
const c = createCarousel(track);
|
||||
expect(c.overflow).toBe(false);
|
||||
const seen: number[] = [];
|
||||
c.on('change', (i) => seen.push(i));
|
||||
|
||||
// content now exceeds the track — MutationObserver must pick it up, no RO fire
|
||||
Object.defineProperty(track, 'scrollWidth', { value: 120, configurable: true });
|
||||
track.append(document.createElement('div'));
|
||||
await Promise.resolve(); // MutationObserver is async (microtask)
|
||||
expect(c.overflow).toBe(true);
|
||||
expect(seen.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('removing the current slide clamps index into range', async () => {
|
||||
const c = createCarousel(track);
|
||||
(IOShim.last as IOShim).emit(2);
|
||||
expect(c.index).toBe(2);
|
||||
track.removeChild(track.children[2]);
|
||||
track.removeChild(track.children[1]);
|
||||
await Promise.resolve(); // MutationObserver is async (microtask)
|
||||
expect(c.index).toBe(0);
|
||||
expect(c.atEnd).toBe(true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user