From 42b4ca68778805864ab7f796c3833767816c5fad Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Wed, 1 Jul 2026 07:58:04 +0300 Subject: [PATCH] feat: autoplay() module with hover/focus pause and reduce-motion guard --- src/autoplay.ts | 48 +++++++++++++++++++++++++++ tests/autoplay.test.ts | 73 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 src/autoplay.ts create mode 100644 tests/autoplay.test.ts diff --git a/src/autoplay.ts b/src/autoplay.ts new file mode 100644 index 0000000..34dff03 --- /dev/null +++ b/src/autoplay.ts @@ -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 | 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); + }; +} diff --git a/tests/autoplay.test.ts b/tests/autoplay.test.ts new file mode 100644 index 0000000..9de856d --- /dev/null +++ b/tests/autoplay.test.ts @@ -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(); +});