feat: edge flags, overflow, and add/remove-aware observers #4
@@ -5,6 +5,11 @@ at each `v*` release tag.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.1.0] - 2026-07-01
|
||||||
|
|
||||||
- Initial core (`createCarousel`), `dots()` fallback, `autoplay()`, and `carousel.css`.
|
- Initial core (`createCarousel`), `dots()` fallback, `autoplay()`, and `carousel.css`.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ilia/carousel",
|
"name": "@ilia/carousel",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
"*.css"
|
"*.css"
|
||||||
|
|||||||
+50
-1
@@ -6,6 +6,9 @@ export type Carousel = {
|
|||||||
scrollToIndex(i: number): void;
|
scrollToIndex(i: number): void;
|
||||||
readonly index: number;
|
readonly index: number;
|
||||||
readonly count: number;
|
readonly count: number;
|
||||||
|
readonly atStart: boolean;
|
||||||
|
readonly atEnd: boolean;
|
||||||
|
readonly overflow: boolean;
|
||||||
on(evt: CarouselEvent, cb: (index: number) => void): () => void;
|
on(evt: CarouselEvent, cb: (index: number) => void): () => void;
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
};
|
};
|
||||||
@@ -18,6 +21,11 @@ export function createCarousel(track: HTMLElement): Carousel {
|
|||||||
const slides = () => Array.from(track.children) as HTMLElement[];
|
const slides = () => Array.from(track.children) as HTMLElement[];
|
||||||
let index = 0;
|
let index = 0;
|
||||||
const listeners = new Set<(i: number) => void>();
|
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));
|
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);
|
const i = slides().indexOf(best.target as HTMLElement);
|
||||||
if (i !== -1 && i !== index) {
|
if (i !== -1 && i !== index) {
|
||||||
index = i;
|
index = i;
|
||||||
for (const cb of listeners) cb(index);
|
notify();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ root: track, threshold: 0.6 },
|
{ root: track, threshold: 0.6 },
|
||||||
);
|
);
|
||||||
for (const s of slides()) io.observe(s);
|
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 {
|
return {
|
||||||
next: () => scrollToIndex(index + 1),
|
next: () => scrollToIndex(index + 1),
|
||||||
prev: () => scrollToIndex(index - 1),
|
prev: () => scrollToIndex(index - 1),
|
||||||
@@ -60,12 +98,23 @@ export function createCarousel(track: HTMLElement): Carousel {
|
|||||||
get count() {
|
get count() {
|
||||||
return slides().length;
|
return slides().length;
|
||||||
},
|
},
|
||||||
|
get atStart() {
|
||||||
|
return index === 0;
|
||||||
|
},
|
||||||
|
get atEnd() {
|
||||||
|
return index >= slides().length - 1;
|
||||||
|
},
|
||||||
|
get overflow() {
|
||||||
|
return overflow;
|
||||||
|
},
|
||||||
on(_evt, cb) {
|
on(_evt, cb) {
|
||||||
listeners.add(cb);
|
listeners.add(cb);
|
||||||
return () => listeners.delete(cb);
|
return () => listeners.delete(cb);
|
||||||
},
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
io.disconnect();
|
io.disconnect();
|
||||||
|
mo.disconnect();
|
||||||
|
ro.disconnect();
|
||||||
listeners.clear();
|
listeners.clear();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,6 +29,23 @@ class IOShim {
|
|||||||
}
|
}
|
||||||
vi.stubGlobal('IntersectionObserver', 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 {
|
function makeTrack(n: number): HTMLElement {
|
||||||
const track = document.createElement('div');
|
const track = document.createElement('div');
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
@@ -85,3 +102,76 @@ test('unsubscribe stops delivery; destroy disconnects observer', () => {
|
|||||||
c.destroy();
|
c.destroy();
|
||||||
expect(spy).toHaveBeenCalled();
|
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