Merge pull request 'Feat/carousel impl' (#1) from feat/carousel-impl into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -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
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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,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
@@ -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
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user