feat: autoplay() module with hover/focus pause and reduce-motion guard
This commit is contained in:
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user