chore: scaffold carousel package and tooling
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
|||||||
|
# build / tooling
|
||||||
|
node_modules
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/releases
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
|
||||||
|
# local-only docs
|
||||||
|
docs/
|
||||||
|
logs_llm/
|
||||||
|
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
@ilia:registry=https://git.allmy.work/api/packages/ilia/npm/
|
||||||
|
//git.allmy.work/api/packages/ilia/npm/:_authToken=${NODE_AUTH_TOKEN}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[
|
||||||
|
{ "name": "core", "path": "dist/index.js", "limit": "1 KB" },
|
||||||
|
{ "name": "dots", "path": "dist/dots.js", "limit": "0.6 KB" },
|
||||||
|
{ "name": "autoplay", "path": "dist/autoplay.js", "limit": "0.6 KB" }
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
nodeLinker: node-modules
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.4.13/schema.json",
|
||||||
|
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
|
||||||
|
"files": { "includes": ["src/**/*", "tests/**/*", "e2e/**/*", "*.ts", "*.json"] },
|
||||||
|
"formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, "lineWidth": 120 },
|
||||||
|
"linter": { "enabled": true, "rules": { "recommended": true } },
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"semicolons": "always",
|
||||||
|
"trailingCommas": "all"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
# Carousel — design
|
|
||||||
|
|
||||||
> **Status:** approved design (2026-06-30). Package name provisional (`carousel`) — rename before publish.
|
|
||||||
|
|
||||||
A tiny, framework-agnostic carousel. Native scroll does the heavy lifting; CSS
|
|
||||||
does the motion; JavaScript is a thin, opt-in fallback. Size is the priority.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
- Smallest realistic footprint. Native platform features before code.
|
|
||||||
- Responsive and touch-first via **native scroll** — no hand-rolled drag/momentum.
|
|
||||||
- Modern CSS (scroll-snap, scroll-driven animations, native scroll markers/buttons).
|
|
||||||
- Controls as **opt-in modules** the developer wires to their own markup.
|
|
||||||
- Evergreen browsers with graceful degradation. No legacy polyfills.
|
|
||||||
|
|
||||||
## Non-goals (deferred — add when asked)
|
|
||||||
|
|
||||||
- Infinite/looping mode (needs slide cloning; fights scroll-snap).
|
|
||||||
- Fade/swap (transform-driven) variant.
|
|
||||||
- Modules generating their own DOM or shipping CSS.
|
|
||||||
|
|
||||||
## Browser target
|
|
||||||
|
|
||||||
Evergreen + graceful degrade. Baseline as of mid-2026:
|
|
||||||
|
|
||||||
| Feature | Chrome | Safari | Firefox | Use |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `scroll-snap`, scrollbar hiding | ✅ years | ✅ years | ✅ years | **Core path** — universal |
|
|
||||||
| `scroll-snap-stop: always` | ✅ | ✅ | ✅ | One-swipe-one-slide |
|
|
||||||
| CSS scroll-driven animations (`animation-timeline: view()`) | ✅ | ⚠️ partial | ✅ | Opt-in per-slide effects; degrades to plain snap |
|
|
||||||
| `::scroll-marker` / `::scroll-button` | ✅ 135+ | ✅ 18.2+ | ⚠️ partial | **CSS-first controls**; JS `dots()` is the FF/legacy fallback |
|
|
||||||
| `scrollsnapchange` event | ✅ | ✅ | ❌ (mid-2026) | Deferred index-tracking upgrade; **IntersectionObserver** used today |
|
|
||||||
| `scroll-state(snapped:)` container queries | ✅ | ❌ | ❌ | Skipped — not Baseline |
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Three independently tree-shakeable layers:
|
|
||||||
|
|
||||||
1. **Core** — `createCarousel(track, opts?)` wraps one scroll container, returns an
|
|
||||||
instance. Zero deps.
|
|
||||||
2. **Sugar modules** (opt-in imports) — `dots()`, `autoplay()`. Wire the developer's
|
|
||||||
**existing** markup to the instance. Create no DOM, ship no CSS.
|
|
||||||
3. **CSS** — a stylesheet the developer imports/copies: snap track + optional
|
|
||||||
scroll-driven effect keyframes. The motion lives here, not in JS.
|
|
||||||
|
|
||||||
**CSS-first controls.** Native `::scroll-marker` (dots) and `::scroll-button` (arrows)
|
|
||||||
are the default in Chrome 135+ / Safari 18.2+ — zero JS. The `dots()` JS module is a
|
|
||||||
**progressive fallback** that engages only where native markers are unsupported
|
|
||||||
(Firefox, older browsers). Arrows need no module at all — the developer calls
|
|
||||||
`c.next()` / `c.prev()` from their own click handlers.
|
|
||||||
|
|
||||||
## Core instance API
|
|
||||||
|
|
||||||
```ts
|
|
||||||
type Carousel = {
|
|
||||||
next(): void;
|
|
||||||
prev(): void;
|
|
||||||
scrollToIndex(i: number): void;
|
|
||||||
readonly index: number; // current snapped slide (leftmost)
|
|
||||||
readonly count: number; // slide count
|
|
||||||
on(evt: 'change', cb: (index: number) => void): () => void; // returns unsubscribe
|
|
||||||
destroy(): void;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Index tracking** = `IntersectionObserver` on slides. Accurate, fires on swipe too —
|
|
||||||
no scroll-position math. Single code path across all browsers.
|
|
||||||
- *Deferred upgrade:* swap to `scrollsnapchange` (snapped element handed to you
|
|
||||||
directly) once Firefox ships it; drop the observer then.
|
|
||||||
- `next` / `prev` / `scrollToIndex` = `el.scrollTo()` (or `scrollIntoView`) to the
|
|
||||||
target child's offset. Smooth scroll + snap finish the job.
|
|
||||||
- `destroy()` disconnects the observer and removes listeners.
|
|
||||||
|
|
||||||
## Responsive & touch
|
|
||||||
|
|
||||||
- **Touch / drag / momentum:** 100% native scroll. Zero JS.
|
|
||||||
- **Items-per-view:** pure CSS — the developer sizes slides
|
|
||||||
(`flex: 0 0 80%` → `33%` at a breakpoint). Core is count-agnostic; `index` is the
|
|
||||||
leftmost snapped slide.
|
|
||||||
- **Scrollbar hidden** (`scrollbar-width: none` + `::-webkit-scrollbar`). Markers /
|
|
||||||
arrows are the affordance; on touch the gesture is self-evident.
|
|
||||||
|
|
||||||
## Effects (the "tactile" feel)
|
|
||||||
|
|
||||||
Pure CSS `animation-timeline: view()` on slides — scale / opacity react to scroll
|
|
||||||
position live as a slide nears center. Ships as an **optional** CSS snippet; the
|
|
||||||
developer opts in by adding a class. Degrades to plain snap where unsupported
|
|
||||||
(Safari partial today). No JS.
|
|
||||||
|
|
||||||
## CSS sketch
|
|
||||||
|
|
||||||
```css
|
|
||||||
.track {
|
|
||||||
display: flex;
|
|
||||||
overflow-x: auto;
|
|
||||||
scroll-snap-type: x mandatory;
|
|
||||||
scrollbar-width: none; /* Firefox */
|
|
||||||
}
|
|
||||||
.track::-webkit-scrollbar { display: none; } /* Chrome/Safari */
|
|
||||||
|
|
||||||
.slide {
|
|
||||||
flex: 0 0 80%;
|
|
||||||
scroll-snap-align: center;
|
|
||||||
scroll-snap-stop: always; /* one swipe = one slide */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* opt-in: tactile effect, degrades to plain snap */
|
|
||||||
.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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CSS-first controls (Chrome 135+ / Safari 18.2+); JS dots() fills FF */
|
|
||||||
.track { scroll-marker-group: after; }
|
|
||||||
.slide::scroll-marker { /* dot styling */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Package shape
|
|
||||||
|
|
||||||
```
|
|
||||||
src/index.ts // createCarousel — core
|
|
||||||
src/dots.ts // dots(c, container) — FF/legacy fallback
|
|
||||||
src/autoplay.ts // autoplay(c, opts)
|
|
||||||
src/carousel.css // snap track + optional effects + native markers
|
|
||||||
```
|
|
||||||
|
|
||||||
Separate entry points → import only what you use. No runtime dependencies. Build with
|
|
||||||
`tsc` + a small bundler (tsup or equivalent).
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- **Core logic** (index math, `change` emit, `destroy` cleanup) — unit tests, jsdom
|
|
||||||
with a fake `IntersectionObserver` shim.
|
|
||||||
- **Snap / scroll behavior** — one Playwright smoke test in a real browser. jsdom
|
|
||||||
can't scroll.
|
|
||||||
|
|
||||||
## Sources
|
|
||||||
|
|
||||||
- [Carousels with CSS — Chrome for Developers](https://developer.chrome.com/blog/carousels-with-css)
|
|
||||||
- [`::scroll-button()` — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/::scroll-button)
|
|
||||||
- [Creating CSS carousels — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Overflow/Carousels)
|
|
||||||
- [Using scroll snap events — MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll_snap/Using_scroll_snap_events)
|
|
||||||
- [CSS `scroll-state()` container queries — Chrome for Developers](https://developer.chrome.com/blog/css-scroll-state-queries)
|
|
||||||
@@ -1,707 +0,0 @@
|
|||||||
# Carousel Implementation Plan
|
|
||||||
|
|
||||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
||||||
|
|
||||||
**Goal:** Build a tiny, framework-agnostic scroll-snap carousel: a zero-dep core plus opt-in `dots()`/`autoplay()` modules and a CSS file.
|
|
||||||
|
|
||||||
**Architecture:** Native scroll handles touch/momentum; CSS handles snap and motion; JS is a thin layer. Core `createCarousel(track)` returns an instance (`next/prev/scrollToIndex/index/count/on/destroy`) with index tracking via IntersectionObserver. Controls are CSS-first (native `::scroll-marker`/`::scroll-button`); JS `dots()` is the Firefox/legacy fallback.
|
|
||||||
|
|
||||||
**Tech Stack:** TypeScript, tsup (build), Vitest + jsdom (unit), Playwright (browser smoke). No runtime deps.
|
|
||||||
|
|
||||||
Design reference: `docs/plans/2026-06-30-carousel-design.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: Scaffold the package
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `package.json`, `tsconfig.json`, `tsup.config.ts`, `vitest.config.ts`, `.gitignore`, `src/index.ts`
|
|
||||||
|
|
||||||
**Step 1: Write `package.json`**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "carousel",
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"sideEffects": ["*.css"],
|
|
||||||
"exports": {
|
|
||||||
".": "./dist/index.js",
|
|
||||||
"./dots": "./dist/dots.js",
|
|
||||||
"./autoplay": "./dist/autoplay.js",
|
|
||||||
"./carousel.css": "./src/carousel.css"
|
|
||||||
},
|
|
||||||
"files": ["dist", "src/carousel.css"],
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsup src/index.ts src/dots.ts src/autoplay.ts --format esm --dts",
|
|
||||||
"test": "vitest run",
|
|
||||||
"test:e2e": "playwright test",
|
|
||||||
"check": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@playwright/test": "^1",
|
|
||||||
"jsdom": "^25",
|
|
||||||
"tsup": "^8",
|
|
||||||
"typescript": "^5",
|
|
||||||
"vitest": "^2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Write `tsconfig.json`**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"strict": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"noEmit": true,
|
|
||||||
"skipLibCheck": true
|
|
||||||
},
|
|
||||||
"include": ["src", "tests"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3: Write `vitest.config.ts`**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
test: { environment: 'jsdom', include: ['tests/**/*.test.ts'] },
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 4: Write `.gitignore`**
|
|
||||||
|
|
||||||
```
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
test-results
|
|
||||||
playwright-report
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 5: Stub `src/index.ts`**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export {};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 6: Install and verify**
|
|
||||||
|
|
||||||
Run: `yarn install && yarn check`
|
|
||||||
Expected: installs cleanly, `tsc` exits 0.
|
|
||||||
|
|
||||||
**Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git commit -m "chore: scaffold carousel package"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: Core `createCarousel` — count, navigation, scrollToIndex
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/index.ts`
|
|
||||||
- Test: `tests/core.test.ts`
|
|
||||||
|
|
||||||
> jsdom has no layout/scroll. Provide a minimal `IntersectionObserver` shim in the
|
|
||||||
> test setup and assert on `scrollTo` calls (spy) rather than real scrolling.
|
|
||||||
|
|
||||||
**Step 1: Write the failing test**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// tests/core.test.ts
|
|
||||||
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"
|
|
||||||
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);
|
|
||||||
// give slides fake offsets
|
|
||||||
(track.children[1] as HTMLElement).offsetLeft; // 0 in jsdom
|
|
||||||
c.next();
|
|
||||||
expect(track.scrollTo).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Run test to verify it fails**
|
|
||||||
|
|
||||||
Run: `yarn test tests/core.test.ts`
|
|
||||||
Expected: FAIL — `createCarousel` not exported.
|
|
||||||
|
|
||||||
**Step 3: Write minimal implementation**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// src/index.ts
|
|
||||||
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: whichever slide is most intersecting is current
|
|
||||||
const io = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
const hit = entries.find((e) => e.isIntersecting);
|
|
||||||
if (!hit) return;
|
|
||||||
const i = slides().indexOf(hit.target as HTMLElement);
|
|
||||||
if (i !== -1 && i !== index) {
|
|
||||||
index = i;
|
|
||||||
listeners.forEach((cb) => cb(index));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ root: track, threshold: 0.6 },
|
|
||||||
);
|
|
||||||
slides().forEach((s) => 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();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 4: Run test to verify it passes**
|
|
||||||
|
|
||||||
Run: `yarn test tests/core.test.ts`
|
|
||||||
Expected: PASS (2 tests).
|
|
||||||
|
|
||||||
**Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/index.ts tests/core.test.ts
|
|
||||||
git commit -m "feat: carousel core (count, navigation, scrollToIndex)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: Core — `change` event fires on snap, `destroy` cleans up
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Test: `tests/core.test.ts` (add cases)
|
|
||||||
|
|
||||||
**Step 1: Write failing tests**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Run to verify**
|
|
||||||
|
|
||||||
Run: `yarn test tests/core.test.ts`
|
|
||||||
Expected: PASS — implementation from Task 2 already covers these. If any fail, fix `src/index.ts` minimally.
|
|
||||||
|
|
||||||
**Step 3: Commit (only if code changed)**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git commit -m "test: carousel core change/destroy edge cases"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: `dots()` fallback module
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/dots.ts`
|
|
||||||
- Test: `tests/dots.test.ts`
|
|
||||||
|
|
||||||
> Wires the developer's existing dot elements. Click → `scrollToIndex`. `change` →
|
|
||||||
> toggle `aria-current`/`.active`. Engages only when native `::scroll-marker` is
|
|
||||||
> unsupported (feature-detect via `CSS.supports('selector(::scroll-marker)')`); in
|
|
||||||
> tests we force-enable by passing the dots explicitly.
|
|
||||||
|
|
||||||
**Step 1: Write failing test**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// tests/dots.test.ts
|
|
||||||
import { 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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('clicking a dot scrolls to its index', () => {
|
|
||||||
const c = fakeCarousel();
|
|
||||||
const container = document.createElement('div');
|
|
||||||
container.innerHTML = '<button></button><button></button><button></button>';
|
|
||||||
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 = document.createElement('div');
|
|
||||||
container.innerHTML = '<button></button><button></button><button></button>';
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Run to verify it fails**
|
|
||||||
|
|
||||||
Run: `yarn test tests/dots.test.ts`
|
|
||||||
Expected: FAIL — `dots` not found.
|
|
||||||
|
|
||||||
**Step 3: Implement**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// src/dots.ts
|
|
||||||
import type { Carousel } from './index.ts';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wire the developer's existing dot elements to a carousel.
|
|
||||||
* Progressive fallback: prefer native ::scroll-marker where supported.
|
|
||||||
* @param c - carousel instance
|
|
||||||
* @param container - element whose children are the dot controls
|
|
||||||
*/
|
|
||||||
export function dots(c: Carousel, container: HTMLElement): () => void {
|
|
||||||
const items = Array.from(container.children) as HTMLElement[];
|
|
||||||
|
|
||||||
const onClick = (i: number) => () => c.scrollToIndex(i);
|
|
||||||
const handlers = items.map((el, i) => {
|
|
||||||
const h = onClick(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]));
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 4: Run to verify it passes**
|
|
||||||
|
|
||||||
Run: `yarn test tests/dots.test.ts`
|
|
||||||
Expected: PASS (2 tests).
|
|
||||||
|
|
||||||
**Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/dots.ts tests/dots.test.ts
|
|
||||||
git commit -m "feat: dots() fallback control module"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: `autoplay()` module
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/autoplay.ts`
|
|
||||||
- Test: `tests/autoplay.test.ts`
|
|
||||||
|
|
||||||
> Timer calls `c.next()`. Pause on `pointerenter`/`focusin`, resume on leave/blur,
|
|
||||||
> stop on manual interaction. Loops back to 0 at the end. Use fake timers.
|
|
||||||
|
|
||||||
**Step 1: Write failing test**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// tests/autoplay.test.ts
|
|
||||||
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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => vi.useFakeTimers());
|
|
||||||
afterEach(() => vi.useRealTimers());
|
|
||||||
|
|
||||||
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('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();
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Run to verify it fails**
|
|
||||||
|
|
||||||
Run: `yarn test tests/autoplay.test.ts`
|
|
||||||
Expected: FAIL — `autoplay` not found.
|
|
||||||
|
|
||||||
**Step 3: Implement**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// src/autoplay.ts
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-advance a carousel, pausing on hover/focus.
|
|
||||||
* @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 tick = () => c.next();
|
|
||||||
const start = () => { 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);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 4: Run to verify it passes**
|
|
||||||
|
|
||||||
Run: `yarn test tests/autoplay.test.ts`
|
|
||||||
Expected: PASS (3 tests).
|
|
||||||
|
|
||||||
**Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/autoplay.ts tests/autoplay.test.ts
|
|
||||||
git commit -m "feat: autoplay() module with hover/focus pause"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6: `carousel.css`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/carousel.css`
|
|
||||||
|
|
||||||
> No test — it's static CSS, validated by the Playwright smoke test in Task 7.
|
|
||||||
|
|
||||||
**Step 1: Write the stylesheet**
|
|
||||||
|
|
||||||
```css
|
|
||||||
/* 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; }
|
|
||||||
|
|
||||||
/* 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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/carousel.css
|
|
||||||
git commit -m "feat: carousel.css (snap, native markers, opt-in effects)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 7: Playwright smoke test (real scroll)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `playwright.config.ts`, `e2e/demo.html`, `e2e/smoke.test.ts`
|
|
||||||
|
|
||||||
> jsdom can't scroll. One real-browser test proves snap + `next()` actually move.
|
|
||||||
|
|
||||||
**Step 1: Write `playwright.config.ts`**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { defineConfig } from '@playwright/test';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
testDir: 'e2e',
|
|
||||||
use: { baseURL: 'http://localhost:5173' },
|
|
||||||
webServer: { command: 'npx http-server e2e -p 5173 -s', port: 5173, reuseExistingServer: true },
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Write `e2e/demo.html`**
|
|
||||||
|
|
||||||
A page that imports the built core from `../dist/index.js`, builds a 3-slide track,
|
|
||||||
exposes `window.c = createCarousel(track)`. Include `carousel.css`.
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!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>
|
|
||||||
<script type="module">
|
|
||||||
import { createCarousel } from '../dist/index.js';
|
|
||||||
window.c = createCarousel(document.getElementById('t'));
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3: Write the test**
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// e2e/smoke.test.ts
|
|
||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
test('next() scrolls the track and updates index', async ({ page }) => {
|
|
||||||
await page.goto('/demo.html');
|
|
||||||
const before = await page.evaluate(() => document.getElementById('t')!.scrollLeft);
|
|
||||||
await page.evaluate(() => (window as any).c.next());
|
|
||||||
await page.waitForTimeout(500); // smooth scroll settle
|
|
||||||
const after = await page.evaluate(() => document.getElementById('t')!.scrollLeft);
|
|
||||||
expect(after).toBeGreaterThan(before);
|
|
||||||
await expect.poll(() => page.evaluate(() => (window as any).c.index)).toBe(1);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 4: Build, then run**
|
|
||||||
|
|
||||||
Run: `yarn build && yarn test:e2e`
|
|
||||||
Expected: PASS — `scrollLeft` increases, `index` becomes 1.
|
|
||||||
|
|
||||||
**Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add playwright.config.ts e2e/
|
|
||||||
git commit -m "test: playwright smoke for real scroll behavior"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 8: README + verify the public API
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `README.md`
|
|
||||||
|
|
||||||
**Step 1: Write a short README** — install, the three import paths, a copy-paste
|
|
||||||
example wiring arrows (manual handlers) + `dots()` fallback + native CSS markers, and
|
|
||||||
the browser-support table from the design doc.
|
|
||||||
|
|
||||||
**Step 2: Final verification**
|
|
||||||
|
|
||||||
Run: `yarn check && yarn test && yarn build`
|
|
||||||
Expected: all green; `dist/` contains `index.js`, `dots.js`, `autoplay.js` with `.d.ts`.
|
|
||||||
|
|
||||||
**Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add README.md
|
|
||||||
git commit -m "docs: README with usage and browser support"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes for the executor
|
|
||||||
|
|
||||||
- **DRY/YAGNI:** no loop mode, no DOM generation, no fade variant — explicitly deferred.
|
|
||||||
- **Fold review fixes** into the related task's commit; don't leave "feature + fix" pairs.
|
|
||||||
- The IO shim and fake-carousel helpers are deliberately tiny — don't promote them to a
|
|
||||||
framework.
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
pre-commit:
|
||||||
|
parallel: true
|
||||||
|
commands:
|
||||||
|
biome-check:
|
||||||
|
glob: "*.{ts,json,css}"
|
||||||
|
run: yarn biome check --write {staged_files}
|
||||||
|
stage_fixed: true
|
||||||
|
typecheck:
|
||||||
|
run: yarn check
|
||||||
|
tests:
|
||||||
|
run: yarn test
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "@ilia/carousel",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": ["*.css"],
|
||||||
|
"packageManager": "yarn@4.11.0",
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://git.allmy.work/api/packages/ilia/npm/"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./dist/index.js",
|
||||||
|
"./dots": "./dist/dots.js",
|
||||||
|
"./autoplay": "./dist/autoplay.js",
|
||||||
|
"./carousel.css": "./src/carousel.css"
|
||||||
|
},
|
||||||
|
"files": ["dist", "src/carousel.css"],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"check": "tsc --noEmit",
|
||||||
|
"lint": "biome check .",
|
||||||
|
"format": "biome format --write .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"size": "size-limit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "^2",
|
||||||
|
"@playwright/test": "^1",
|
||||||
|
"@size-limit/preset-small-lib": "^11",
|
||||||
|
"@vitest/coverage-v8": "^2",
|
||||||
|
"jsdom": "^25",
|
||||||
|
"lefthook": "^1",
|
||||||
|
"size-limit": "^11",
|
||||||
|
"tsup": "^8",
|
||||||
|
"typescript": "^5",
|
||||||
|
"vitest": "^2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src", "tests"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'tsup';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ['src/index.ts', 'src/dots.ts', 'src/autoplay.ts'],
|
||||||
|
format: 'esm',
|
||||||
|
dts: true,
|
||||||
|
clean: true,
|
||||||
|
// unminified on purpose — consumer bundlers minify. size-limit measures the
|
||||||
|
// minified+gzipped size for budgeting.
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['tests/**/*.test.ts'],
|
||||||
|
coverage: {
|
||||||
|
provider: 'v8',
|
||||||
|
include: ['src/**/*.ts'],
|
||||||
|
thresholds: { lines: 90, branches: 90, functions: 90, statements: 90 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user