Feat/carousel impl #1

Merged
ilia merged 11 commits from feat/carousel-impl into main 2026-07-01 06:30:09 +00:00
2 changed files with 121 additions and 0 deletions
Showing only changes of commit 42b4ca6877 - Show all commits
+48
View File
@@ -0,0 +1,48 @@
import type { Carousel } from './index.ts';
/** Options for autoplay. */
export type AutoplayOptions = {
/** ms between advances */
interval: number;
/** element whose hover/focus pauses playback (usually the track wrapper) */
root: HTMLElement;
};
const prefersReducedMotion = () =>
typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
/**
* Auto-advance a carousel, pausing on hover/focus. Never starts when the user
* prefers reduced motion (WCAG / respects OS setting). A visible pause control
* (WCAG 2.2.2) is the developer's responsibility — wire it to the returned stop().
* @param c - carousel instance
* @param opts - interval (ms) and the root element to bind pause events to
* @returns stop function that halts playback and removes listeners
*/
export function autoplay(c: Carousel, opts: AutoplayOptions): () => void {
let timer: ReturnType<typeof setInterval> | undefined;
const reduce = prefersReducedMotion();
const tick = () => c.next();
const start = () => {
if (!reduce) timer ??= setInterval(tick, opts.interval);
};
const pause = () => {
clearInterval(timer);
timer = undefined;
};
opts.root.addEventListener('pointerenter', pause);
opts.root.addEventListener('pointerleave', start);
opts.root.addEventListener('focusin', pause);
opts.root.addEventListener('focusout', start);
start();
return () => {
pause();
opts.root.removeEventListener('pointerenter', pause);
opts.root.removeEventListener('pointerleave', start);
opts.root.removeEventListener('focusin', pause);
opts.root.removeEventListener('focusout', start);
};
}
+73
View File
@@ -0,0 +1,73 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { autoplay } from '../src/autoplay.ts';
function fakeCarousel(count = 3) {
let index = 0;
return {
get index() {
return index;
},
count,
next: vi.fn(() => {
index = (index + 1) % count;
}),
prev: vi.fn(),
scrollToIndex: vi.fn(),
on: () => () => {},
destroy: vi.fn(),
};
}
// jsdom has no matchMedia — stub it. reduce = whether reduce-motion matches.
function stubMatchMedia(reduce: boolean) {
vi.stubGlobal('matchMedia', (q: string) => ({
matches: reduce && q.includes('reduce'),
media: q,
addEventListener() {},
removeEventListener() {},
}));
}
beforeEach(() => {
vi.useFakeTimers();
stubMatchMedia(false);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
test('advances every interval', () => {
const c = fakeCarousel();
autoplay(c as never, { interval: 1000, root: document.createElement('div') });
vi.advanceTimersByTime(2000);
expect(c.next).toHaveBeenCalledTimes(2);
});
test('pause on pointerenter, resume on pointerleave', () => {
const c = fakeCarousel();
const root = document.createElement('div');
autoplay(c as never, { interval: 1000, root });
root.dispatchEvent(new Event('pointerenter'));
vi.advanceTimersByTime(3000);
expect(c.next).not.toHaveBeenCalled();
root.dispatchEvent(new Event('pointerleave'));
vi.advanceTimersByTime(1000);
expect(c.next).toHaveBeenCalledTimes(1);
});
test('does not start under prefers-reduced-motion', () => {
stubMatchMedia(true);
const c = fakeCarousel();
autoplay(c as never, { interval: 1000, root: document.createElement('div') });
vi.advanceTimersByTime(5000);
expect(c.next).not.toHaveBeenCalled();
});
test('stop() halts and removes listeners', () => {
const c = fakeCarousel();
const stop = autoplay(c as never, { interval: 1000, root: document.createElement('div') });
stop();
vi.advanceTimersByTime(5000);
expect(c.next).not.toHaveBeenCalled();
});