Merge pull request 'Feat/carousel impl' (#1) from feat/carousel-impl into main
CI / verify (push) Successful in 27s
CI / e2e (push) Successful in 25s
CI / publish (push) Has been skipped

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-07-01 06:30:08 +00:00
16 changed files with 4136 additions and 6 deletions
+84
View File
@@ -0,0 +1,84 @@
name: CI
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
workflow_dispatch:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '25' }
- name: Enable Corepack
run: |
corepack enable
corepack prepare yarn@4.11.0 --activate
- uses: actions/cache@v4
with:
path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-
- run: yarn install --immutable
- name: Lint + format check
run: yarn lint
- name: Type check
run: yarn check
- name: Unit tests + coverage
run: yarn test:coverage
- name: Build
run: yarn build
- name: Size budgets
run: yarn size
e2e:
needs: verify
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.61.1-jammy
steps:
- uses: actions/checkout@v4
- name: Enable Corepack
run: |
corepack enable
corepack prepare yarn@4.11.0 --activate
- uses: actions/cache@v4
with:
path: .yarn/cache
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: ${{ runner.os }}-yarn-
- run: yarn install --immutable
- name: Build
run: yarn build
- name: E2E (chromium + firefox)
timeout-minutes: 15
run: yarn test:e2e
publish:
needs: [verify, e2e]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '25' }
- name: Enable Corepack
run: |
corepack enable
corepack prepare yarn@4.11.0 --activate
- run: yarn install --immutable
- name: Assert tag matches package.json version
run: |
TAG="${GITHUB_REF_NAME#v}"
PKG="$(node -p "require('./package.json').version")"
test "$TAG" = "$PKG" || { echo "tag v$TAG != package.json $PKG"; exit 1; }
- name: Build
run: yarn build
- name: Publish to Gitea registry
env:
NODE_AUTH_TOKEN: ${{ secrets.CI_DEPLOY_TOKEN }}
run: npm publish
+96
View File
@@ -0,0 +1,96 @@
# @ilia/carousel
Tiny, framework-agnostic scroll-snap carousel. Zero runtime deps. Native scroll
handles touch/momentum, CSS handles snap + motion, JS is a thin layer.
- **core** ≤1 KB gzip · **dots** ≤0.6 KB · **autoplay** ≤0.6 KB
- Ships **unminified** ESM — your bundler minifies.
## Install
This package lives on a private Gitea registry. Point the `@ilia` scope at it:
```
# .npmrc
@ilia:registry=https://git.allmy.work/api/packages/ilia/npm/
```
```bash
yarn add @ilia/carousel
```
## Entry points
| Import | What |
|---|---|
| `@ilia/carousel` | `createCarousel(track)` core |
| `@ilia/carousel/dots` | `dots()` — Firefox/legacy dot fallback (self-gates) |
| `@ilia/carousel/autoplay` | `autoplay()` — reduce-motion-aware auto-advance |
| `@ilia/carousel/carousel.css` | snap track, native markers, opt-in `.fx` effect |
## Usage
```html
<div class="track" id="carousel">
<div class="slide"></div>
<div class="slide"></div>
<div class="slide"></div>
</div>
<button id="prev" aria-label="Previous"></button>
<button id="next" aria-label="Next"></button>
<!-- Firefox/legacy dot fallback; native ::scroll-marker owns dots elsewhere -->
<div id="dots" aria-label="Choose slide">
<button aria-label="Slide 1"></button>
<button aria-label="Slide 2"></button>
<button aria-label="Slide 3"></button>
</div>
```
```ts
import { createCarousel } from '@ilia/carousel';
import { dots } from '@ilia/carousel/dots';
import { autoplay } from '@ilia/carousel/autoplay';
import '@ilia/carousel/carousel.css';
const track = document.getElementById('carousel');
const c = createCarousel(track);
// Arrows are just your own handlers — no module needed.
document.getElementById('next').onclick = () => c.next();
document.getElementById('prev').onclick = () => c.prev();
// Dots: no-ops where native ::scroll-marker exists (no double dots).
dots(c, document.getElementById('dots'));
// Opt-in auto-advance. Provide a visible pause control (see a11y note).
const stop = autoplay(c, { interval: 4000, root: track });
```
Add the opt-in scroll-driven effect with `class="track fx"` — it degrades to plain
snap where `animation-timeline: view()` is unsupported.
## Accessibility
- `autoplay()` **never starts** under `prefers-reduced-motion: reduce`, and the `.fx`
effect + smooth scrolling are gated behind `@media (prefers-reduced-motion: no-preference)`.
- If you use `autoplay()`, you **must** provide a visible pause/stop control
(WCAG 2.2.2) — wire it to the returned `stop()`.
- Give dot buttons accessible labels (`aria-label`).
## Browser support
| Feature | Chrome | Safari | Firefox |
|---|---|---|---|
| `scroll-snap` + core path | ✅ | ✅ | ✅ |
| `::scroll-marker` native dots | ✅ 135+ | ✅ 18.2+ | ⚠️ → JS `dots()` fallback |
| scroll-driven `.fx` | ✅ | ⚠️ partial → plain snap | ✅ |
Index tracking uses IntersectionObserver (universal today); a `scrollsnapchange`
upgrade is deferred until Firefox ships it.
## Release
```bash
npm version <patch|minor|major>
git push --follow-tags # CI verifies, runs e2e, publishes on the v* tag
```
+2 -2
View File
@@ -1,9 +1,9 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.4.13/schema.json", "$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": { "includes": ["src/**/*", "tests/**/*", "e2e/**/*", "*.ts", "*.json"] }, "files": { "includes": ["src/**/*", "tests/**/*", "e2e/**/*", "*.ts", "*.json"] },
"formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, "lineWidth": 120 }, "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, "lineWidth": 120 },
"linter": { "enabled": true, "rules": { "recommended": true } }, "linter": { "enabled": true, "rules": { "preset": "recommended" } },
"javascript": { "javascript": {
"formatter": { "formatter": {
"quoteStyle": "single", "quoteStyle": "single",
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<link rel="stylesheet" href="../src/carousel.css" />
<div class="track" id="t" style="width:300px">
<div class="slide" id="s0">0</div>
<div class="slide" id="s1">1</div>
<div class="slide" id="s2">2</div>
</div>
<div id="dots">
<button type="button" aria-label="Slide 1"></button>
<button type="button" aria-label="Slide 2"></button>
<button type="button" aria-label="Slide 3"></button>
</div>
<script type="module">
import { dots } from '../dist/dots.js';
import { createCarousel } from '../dist/index.js';
const c = createCarousel(document.getElementById('t'));
dots(c, document.getElementById('dots'));
window.c = c;
</script>
+29
View File
@@ -0,0 +1,29 @@
import { expect, test } from '@playwright/test';
declare global {
interface Window {
c: { next(): void; readonly index: number };
}
}
const scrollLeft = () => document.getElementById('t')?.scrollLeft ?? 0;
test('next() scrolls the track and updates index', async ({ page }) => {
await page.goto('/e2e/demo.html');
const before = await page.evaluate(scrollLeft);
await page.evaluate(() => window.c.next());
await page.waitForTimeout(500); // smooth scroll settle
const after = await page.evaluate(scrollLeft);
expect(after).toBeGreaterThan(before);
await expect.poll(() => page.evaluate(() => window.c.index)).toBe(1);
});
test('clicking a dot navigates (fallback path on Firefox)', async ({ page, browserName }) => {
// dots() self-gates: on Chromium native ::scroll-marker is present, so it no-ops.
// Firefox lacks native markers — the JS fallback is the path that must pass.
test.skip(browserName !== 'firefox', 'dots() no-ops where native ::scroll-marker exists');
await page.goto('/e2e/demo.html');
await page.locator('#dots button').nth(2).click();
await page.waitForTimeout(500);
await expect.poll(() => page.evaluate(() => window.c.index)).toBe(2);
});
+8 -3
View File
@@ -2,7 +2,9 @@
"name": "@ilia/carousel", "name": "@ilia/carousel",
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"sideEffects": ["*.css"], "sideEffects": [
"*.css"
],
"packageManager": "yarn@4.11.0", "packageManager": "yarn@4.11.0",
"publishConfig": { "publishConfig": {
"registry": "https://git.allmy.work/api/packages/ilia/npm/" "registry": "https://git.allmy.work/api/packages/ilia/npm/"
@@ -13,7 +15,10 @@
"./autoplay": "./dist/autoplay.js", "./autoplay": "./dist/autoplay.js",
"./carousel.css": "./src/carousel.css" "./carousel.css": "./src/carousel.css"
}, },
"files": ["dist", "src/carousel.css"], "files": [
"dist",
"src/carousel.css"
],
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"check": "tsc --noEmit", "check": "tsc --noEmit",
@@ -26,7 +31,7 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2", "@biomejs/biome": "^2",
"@playwright/test": "^1", "@playwright/test": "1.61.1",
"@size-limit/preset-small-lib": "^11", "@size-limit/preset-small-lib": "^11",
"@vitest/coverage-v8": "^2", "@vitest/coverage-v8": "^2",
"jsdom": "^25", "jsdom": "^25",
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig, devices } from '@playwright/test';
const isCI = !!process.env.CI;
export default defineConfig({
testDir: 'e2e',
testMatch: /.*\.test\.ts$/,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
reporter: isCI ? [['html', { open: 'never' }], ['github']] : [['list']],
use: { baseURL: 'http://localhost:5173' },
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
],
webServer: {
command: 'yarn dlx http-server . -p 5173 -s',
port: 5173,
reuseExistingServer: !isCI,
},
});
+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);
};
}
+57
View File
@@ -0,0 +1,57 @@
/* carousel.css — snap track, hidden scrollbar, native markers, opt-in effects */
.track {
display: flex;
gap: 1rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
scrollbar-width: none;
scroll-marker-group: after; /* native dots where supported */
}
.track::-webkit-scrollbar {
display: none;
}
.slide {
flex: 0 0 80%;
scroll-snap-align: center;
scroll-snap-stop: always; /* one swipe = one slide */
}
/* native CSS markers (Chrome 135+, Safari 18.2+) */
.slide::scroll-marker {
content: "";
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: currentColor;
opacity: 0.4;
}
.slide::scroll-marker:target-current {
opacity: 1;
}
/* Motion only when the user hasn't asked to reduce it. */
@media (prefers-reduced-motion: no-preference) {
.track {
scroll-behavior: smooth;
}
/* opt-in tactile effect; degrades to plain snap where unsupported */
@supports (animation-timeline: view()) {
.track.fx .slide {
animation: slide-fx linear both;
animation-timeline: view(inline);
}
@keyframes slide-fx {
entry 0%,
exit 100% {
scale: 0.9;
opacity: 0.5;
}
cover 50% {
scale: 1;
opacity: 1;
}
}
}
}
+47
View File
@@ -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]);
});
};
}
+72 -1
View File
@@ -1 +1,72 @@
export {}; export type CarouselEvent = 'change';
export type Carousel = {
next(): void;
prev(): void;
scrollToIndex(i: number): void;
readonly index: number;
readonly count: number;
on(evt: CarouselEvent, cb: (index: number) => void): () => void;
destroy(): void;
};
/**
* Wrap a scroll-snap track element and return a carousel controller.
* @param track - the overflow-x scroll container whose children are slides
*/
export function createCarousel(track: HTMLElement): Carousel {
const slides = () => Array.from(track.children) as HTMLElement[];
let index = 0;
const listeners = new Set<(i: number) => void>();
const clamp = (i: number) => Math.max(0, Math.min(i, slides().length - 1));
function scrollToIndex(i: number) {
const target = slides()[clamp(i)];
if (target) {
track.scrollTo({ left: target.offsetLeft, behavior: 'smooth' });
}
}
// index tracking: the most-intersecting slide is current. Picking max ratio
// (not the first intersecting) avoids a transient wrong index mid-swipe when
// two slides cross the threshold in one callback.
const io = new IntersectionObserver(
(entries) => {
let best: IntersectionObserverEntry | undefined;
for (const e of entries) {
if (e.isIntersecting && (!best || e.intersectionRatio > best.intersectionRatio)) {
best = e;
}
}
if (!best) return;
const i = slides().indexOf(best.target as HTMLElement);
if (i !== -1 && i !== index) {
index = i;
for (const cb of listeners) cb(index);
}
},
{ root: track, threshold: 0.6 },
);
for (const s of slides()) io.observe(s);
return {
next: () => scrollToIndex(index + 1),
prev: () => scrollToIndex(index - 1),
scrollToIndex,
get index() {
return index;
},
get count() {
return slides().length;
},
on(_evt, cb) {
listeners.add(cb);
return () => listeners.delete(cb);
},
destroy() {
io.disconnect();
listeners.clear();
},
};
}
+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();
});
+87
View File
@@ -0,0 +1,87 @@
import { beforeEach, expect, test, vi } from 'vitest';
import { createCarousel } from '../src/index.ts';
// Minimal IO shim — records instances so tests can fire entries manually.
class IOShim {
static last: IOShim | null = null;
cb: IntersectionObserverCallback;
elements: Element[] = [];
constructor(cb: IntersectionObserverCallback) {
this.cb = cb;
IOShim.last = this;
}
observe(el: Element) {
this.elements.push(el);
}
unobserve() {}
disconnect() {}
// helper: emit "slide i is the one intersecting" (ratio 1), others 0
emit(i: number) {
this.cb(
this.elements.map((target, idx) => ({
target,
isIntersecting: idx === i,
intersectionRatio: idx === i ? 1 : 0,
})) as unknown as IntersectionObserverEntry[],
this as unknown as IntersectionObserver,
);
}
}
vi.stubGlobal('IntersectionObserver', IOShim);
function makeTrack(n: number): HTMLElement {
const track = document.createElement('div');
for (let i = 0; i < n; i++) {
const slide = document.createElement('div');
slide.className = 'slide';
track.append(slide);
}
track.scrollTo = vi.fn();
return track;
}
let track: HTMLElement;
beforeEach(() => {
track = makeTrack(3);
});
test('count reflects slide children', () => {
const c = createCarousel(track);
expect(c.count).toBe(3);
});
test('next/prev clamp to range and scroll to target offset', () => {
const c = createCarousel(track);
c.next();
expect(track.scrollTo).toHaveBeenCalled();
});
test('change fires with new index when a slide intersects', () => {
const c = createCarousel(track);
const seen: number[] = [];
c.on('change', (i) => seen.push(i));
(IOShim.last as IOShim).emit(2); // user swiped to slide 2
expect(seen).toEqual([2]);
expect(c.index).toBe(2);
});
test('change does not re-fire for the same index', () => {
const c = createCarousel(track);
const seen: number[] = [];
c.on('change', (i) => seen.push(i));
(IOShim.last as IOShim).emit(1);
(IOShim.last as IOShim).emit(1);
expect(seen).toEqual([1]);
});
test('unsubscribe stops delivery; destroy disconnects observer', () => {
const c = createCarousel(track);
const seen: number[] = [];
const off = c.on('change', (i) => seen.push(i));
off();
(IOShim.last as IOShim).emit(2);
expect(seen).toEqual([]);
const spy = vi.spyOn(IOShim.last as IOShim, 'disconnect');
c.destroy();
expect(spy).toHaveBeenCalled();
});
+59
View File
@@ -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 = '<button></button><button></button><button></button>';
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();
});
+1
View File
@@ -5,6 +5,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"strict": true, "strict": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"noEmit": true, "noEmit": true,
"skipLibCheck": true "skipLibCheck": true
+3432
View File
File diff suppressed because it is too large Load Diff