From e273cad04b5ac9f7e18954dc6ab1d5e8d0dd422c Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Thu, 2 Jul 2026 11:15:15 +0300 Subject: [PATCH] feat: edge flags, overflow, and add/remove-aware observers --- CHANGELOG.md | 5 +++ package.json | 2 +- src/index.ts | 51 +++++++++++++++++++++++++- tests/core.test.ts | 90 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7499c37..cd4ac2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. diff --git a/package.json b/package.json index 12fc3d4..64485e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ilia/carousel", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "sideEffects": [ "*.css" diff --git a/src/index.ts b/src/index.ts index c198860..06f44da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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(); }, }; diff --git a/tests/core.test.ts b/tests/core.test.ts index 2f5bc9a..16fb6ac 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -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); +});