1 Commits
Author SHA1 Message Date
Ilia Mashkov e273cad04b feat: edge flags, overflow, and add/remove-aware observers
CI / verify (push) Successful in 26s
CI / e2e (push) Successful in 26s
CI / verify (pull_request) Successful in 29s
CI / publish (push) Successful in 18s
CI / e2e (pull_request) Successful in 29s
CI / publish (pull_request) Has been skipped
2026-07-02 11:26:45 +03:00
4 changed files with 146 additions and 2 deletions
+5
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "@ilia/carousel",
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"sideEffects": [
"*.css"
+50 -1
View File
@@ -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();
},
};
+90
View File
@@ -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);
});