diff --git a/src/dots.ts b/src/dots.ts new file mode 100644 index 0000000..9d5e539 --- /dev/null +++ b/src/dots.ts @@ -0,0 +1,47 @@ +import type { Carousel } from './index.ts'; + +const NOOP = () => {}; + +/** + * Wire the developer's existing dot elements to a carousel. + * Progressive fallback: no-ops where native ::scroll-marker is supported, so the + * native marker group is the sole control there (no double dots). + * @param c - carousel instance + * @param container - element whose children are the dot controls + * @returns cleanup function (a noop when native markers are used) + */ +export function dots(c: Carousel, container: HTMLElement): () => void { + // Native markers present → let CSS own the controls. + if (typeof CSS !== 'undefined' && CSS.supports('selector(::scroll-marker)')) { + return NOOP; + } + + const items = Array.from(container.children) as HTMLElement[]; + + const handlers = items.map((el, i) => { + const h = () => c.scrollToIndex(i); + el.addEventListener('click', h); + return h; + }); + + function mark(active: number) { + items.forEach((el, i) => { + if (i === active) { + el.setAttribute('aria-current', 'true'); + el.classList.add('active'); + } else { + el.removeAttribute('aria-current'); + el.classList.remove('active'); + } + }); + } + mark(c.index); + const off = c.on('change', mark); + + return () => { + off(); + items.forEach((el, i) => { + el.removeEventListener('click', handlers[i]); + }); + }; +} diff --git a/tests/dots.test.ts b/tests/dots.test.ts new file mode 100644 index 0000000..418aba8 --- /dev/null +++ b/tests/dots.test.ts @@ -0,0 +1,59 @@ +import { afterEach, expect, test, vi } from 'vitest'; +import { dots } from '../src/dots.ts'; + +function fakeCarousel() { + let cb: (i: number) => void = () => {}; + return { + index: 0, + count: 3, + next: vi.fn(), + prev: vi.fn(), + scrollToIndex: vi.fn(), + on: (_e: string, fn: (i: number) => void) => { + cb = fn; + return () => {}; + }, + destroy: vi.fn(), + fire: (i: number) => cb(i), + }; +} + +function dotContainer() { + const el = document.createElement('div'); + el.innerHTML = ''; + return el; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +test('clicking a dot scrolls to its index', () => { + const c = fakeCarousel(); + const container = dotContainer(); + dots(c as never, container); + (container.children[2] as HTMLButtonElement).click(); + expect(c.scrollToIndex).toHaveBeenCalledWith(2); +}); + +test('change marks the active dot with aria-current', () => { + const c = fakeCarousel(); + const container = dotContainer(); + dots(c as never, container); + c.fire(1); + expect(container.children[1].getAttribute('aria-current')).toBe('true'); + expect(container.children[0].hasAttribute('aria-current')).toBe(false); +}); + +test('no-ops (no listeners wired) when native ::scroll-marker is supported', () => { + vi.stubGlobal('CSS', { supports: () => true }); // jsdom has no CSS global + + const c = fakeCarousel(); + const container = dotContainer(); + const cleanup = dots(c as never, container); + (container.children[0] as HTMLButtonElement).click(); + expect(c.scrollToIndex).not.toHaveBeenCalled(); + expect(typeof cleanup).toBe('function'); // noop cleanup, safe to call + cleanup(); +});