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

This commit is contained in:
Ilia Mashkov
2026-07-02 11:26:45 +03:00
parent ba7aa0664a
commit e273cad04b
4 changed files with 146 additions and 2 deletions
+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);
});