diff --git a/.changeset/brave-otters-hum.md b/.changeset/brave-otters-hum.md new file mode 100644 index 0000000..e19584a --- /dev/null +++ b/.changeset/brave-otters-hum.md @@ -0,0 +1,8 @@ +--- +'@ixirjs/pulse': minor +--- + +Emit CSS easing keywords instead of resampling them. An easing that _is_ a CSS +keyword curve (`ease`, `easeIn`, `easeOut`, `easeInOut`, `linear`) now reaches +WAAPI as that keyword rather than a 25-point `linear(…)` approximation of it — +exact timing, smaller keyframes. diff --git a/.changeset/config.json b/.changeset/config.json index ec98e35..199c9ef 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,11 +1,11 @@ { - "$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json", - "changelog": "@changesets/cli/changelog", - "commit": false, - "fixed": [], - "linked": [], - "access": "public", - "baseBranch": "main", - "updateInternalDependencies": "patch", - "ignore": [] + "$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] } diff --git a/.changeset/fuzzy-anchors-flip.md b/.changeset/fuzzy-anchors-flip.md new file mode 100644 index 0000000..045c2c4 --- /dev/null +++ b/.changeset/fuzzy-anchors-flip.md @@ -0,0 +1,5 @@ +--- +'@ixirjs/pulse': minor +--- + +Add a lifecycle-safe `anchoredFlip` transition for mounted overlays, including interruption cleanup, virtual reference support, and reduced-motion handling. diff --git a/.changeset/lazy-otters-shave.md b/.changeset/lazy-otters-shave.md new file mode 100644 index 0000000..8b62691 --- /dev/null +++ b/.changeset/lazy-otters-shave.md @@ -0,0 +1,19 @@ +--- +'@ixirjs/pulse': minor +--- + +Remove the `disabled` option from every gesture attachment. + +It duplicated what Svelte already gives you, and did it worse: `disabled: true` +only short-circuited the setup, so toggling it did nothing to an attachment that +was already live. Conditional attachment is the working form and actually tears +the listeners down: + +```svelte + +
+ + +
+``` + diff --git a/.changeset/lucky-scopes-shrink.md b/.changeset/lucky-scopes-shrink.md new file mode 100644 index 0000000..90e5410 --- /dev/null +++ b/.changeset/lucky-scopes-shrink.md @@ -0,0 +1,5 @@ +--- +'@ixirjs/pulse': minor +--- + +**Breaking:** `createFlipScope()` no longer takes options and no longer returns `clear()`. The `layoutTtlMs` option (and its `CreateFlipScopeOptions` type) is gone — layout records expire after a fixed 250ms, which is already far longer than any unmount/mount handoff, so neither knob had a use. Replace `const { flip, clear } = createFlipScope(opts)` with `const { flip } = createFlipScope()`. diff --git a/.changeset/plenty-moons-tap.md b/.changeset/plenty-moons-tap.md new file mode 100644 index 0000000..9e9e23f --- /dev/null +++ b/.changeset/plenty-moons-tap.md @@ -0,0 +1,12 @@ +--- +'@ixirjs/pulse': minor +--- + +**Breaking:** trimmed duplicated and internal API off the public surface. + +- `flipFromRect`, `flipToRect`, `captureRect`, and the `AnimateFlipRect` type are gone from `@ixirjs/pulse/animate`. They duplicated `flipFrom` / `snapshotRect` in `@ixirjs/pulse/flip`, which carry the richer option set (opacity crossfade, `disablePointerEvents`, distance-derived duration, rect-carrying hooks). Migrate by importing those instead; pass `duration` / `easing` where you passed `AnimateDefaults`. +- `@ixirjs/pulse/flip` no longer exports the delta-math primitives `computeDelta`, `isIdentityDelta`, `diagonal`, `rectsEqual`, or the `FlipDelta` / `DeltaOptions` types. They are internals of the animator. `flip`, `flipFrom`, `snapshotRect`, `measure`, `animateFlip`, `createFlipScope`, `createFlipSwitcher`, and `anchoredFlip` are unchanged. +- `flipTo` stays, as the mirror of `flipFrom`: it animates an element from its current position **to** a captured rect (forward FLIP). `animateFlip({ …, forward: true })` remains the low-level form. +- `@ixirjs/pulse/morph` no longer exports the path-alignment internals `planMorph`, `interpolatePlan`, `toPathString`, `subdivideTo`, `alignSubpaths`, `minimizeAnchorTravel`, `rotateClosed`, `reverseClosed`, or the `MorphPlan` type. `morph`, `parsePath`, and `normalizePath` are unchanged. + +Internally, FLIP geometry now lives in one module (`flip/geometry.ts`) instead of being split across the `animate` and `flip` layers. diff --git a/.changeset/tidy-pans-shave.md b/.changeset/tidy-pans-shave.md new file mode 100644 index 0000000..eb76deb --- /dev/null +++ b/.changeset/tidy-pans-shave.md @@ -0,0 +1,9 @@ +--- +'@ixirjs/pulse': minor +--- + +Trim the public surface and de-duplicate internals. + +- `createFlipAttachment`, `createLayoutBridge`, `createReflowScheduler`, and their types are no longer exported from `@ixirjs/pulse/flip`; they are internals. `flip()` and `createFlipScope()` remain the supported entry points. +- Single RAF batcher (`shared/frame-batch`) now backs both scroll and FLIP reflow scheduling. +- `frameTween` delay is always counted from the first browser frame; the `delayOrigin` option is gone. diff --git a/.changeset/wild-moons-attend.md b/.changeset/wild-moons-attend.md new file mode 100644 index 0000000..e39b254 --- /dev/null +++ b/.changeset/wild-moons-attend.md @@ -0,0 +1,17 @@ +--- +'@ixirjs/pulse': minor +--- + +Replace `flip()`'s `auto` option with reactive `class` / `style` thunks. + +`flip({ class: () => ClassValue })` and `flip({ style: () => string })` let the attachment own +the attribute: it writes the class or inline style itself, then measures and animates the +resulting layout change in the same tick — no discarded thunk and no frame of latency. + +Layout auto-tracking (`ResizeObserver` + parent `MutationObserver`) is now always on, so a +shuffled `{#each}` animates with no option at all. Consequently `auto`, the `FlipAuto` type, and +`createObserverManager` / `ObserverManager` are removed from the public API — delete the `auto:` +option from existing call sites. + +Note: use flip's `class` option or a dynamic `class={…}` in markup, not both on the same element +— Svelte assigns `className` wholesale and would drop flip's tokens. A static `class="…"` is safe. diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 71a4ab9..43f61e3 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1 +1 @@ -- after each fix or change do a global review of the change and how it is consistent with the codebase \ No newline at end of file +- after each fix or change do a global review of the change and how it is consistent with the codebase diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..bff596a --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,25 @@ +name: Verify + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.9' + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: bun install --frozen-lockfile + # One authoritative release-quality command: type/Svelte checks, style, + # browser/server tests, packed-consumer verification, and size budgets. + - run: bun run verify diff --git a/.prettierrc b/.prettierrc index 891c0f8..819fa57 100644 --- a/.prettierrc +++ b/.prettierrc @@ -3,10 +3,7 @@ "singleQuote": true, "trailingComma": "none", "printWidth": 100, - "plugins": [ - "prettier-plugin-svelte", - "prettier-plugin-tailwindcss" - ], + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], "overrides": [ { "files": "*.svelte", diff --git a/.size-limit.json b/.size-limit.json index 9f65b3f..5dc1964 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -1,17 +1,17 @@ [ - { - "name": "Root (animate + flip)", - "path": "./dist/index.js", - "limit": "12 kB" - }, - { - "name": "animate only", - "path": "./dist/animate/index.js", - "limit": "8 kB" - }, - { - "name": "flip only", - "path": "./dist/flip/index.js", - "limit": "6 kB" - } + { + "name": "Root public API", + "path": "./dist/index.js", + "limit": "19 kB" + }, + { + "name": "animate only", + "path": "./dist/animate/index.js", + "limit": "8 kB" + }, + { + "name": "flip only", + "path": "./dist/flip/index.js", + "limit": "7 kB" + } ] diff --git a/.storybook/main.ts b/.storybook/main.ts deleted file mode 100644 index 8c68b2d..0000000 --- a/.storybook/main.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { StorybookConfig } from '@storybook/sveltekit'; - -const config: StorybookConfig = { - "stories": [ - "../src/**/*.mdx", - "../src/**/*.stories.@(js|ts|svelte)" - ], - "addons": [ - "@storybook/addon-svelte-csf", - "@chromatic-com/storybook", - "@storybook/addon-vitest", - "@storybook/addon-a11y", - "@storybook/addon-docs" - ], - "framework": "@storybook/sveltekit" -}; -export default config; \ No newline at end of file diff --git a/.storybook/preview.ts b/.storybook/preview.ts deleted file mode 100644 index 8678238..0000000 --- a/.storybook/preview.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Preview } from '@storybook/sveltekit' - -const preview: Preview = { - parameters: { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, - }, - - a11y: { - // 'todo' - show a11y violations in the test UI only - // 'error' - fail CI on a11y violations - // 'off' - skip a11y checks entirely - test: 'todo' - } - }, -}; - -export default preview; \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index babcb5c..a0bee5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added #### `animate(element, props, defaults?)` + - Spring-physics WAAPI animation runtime with per-property spring curves. - Independent transform component animation (`x`, `y`, `scale`, `rotate`, `skewX`, `skewY`) via registered CSS custom properties — never clobbers sibling transforms. - `[from, to]` shorthand, `PropConfig` full form, and bare-value (current → target) inputs. @@ -18,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `prefers-reduced-motion` support (skips animation and applies end-state). #### `timeline(defaults?)` + - Sequence and parallelize `animate()` calls along a shared clock. - Position grammar: absolute ms, `"+=N"`/`"-=N"`, `"<"`/`">"` anchors with offsets, named labels. - `.add()`, `.set()`, `.call()`, `.label()` builder API (chainable). @@ -25,30 +27,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `finished` promise aggregating all child animations. #### `spring(options?)` + - Simulate a spring from 0 → 1 and return per-frame samples + duration. - Memoized by physics parameters (LRU cache, max 128 entries). - Used internally by `animate()` spring props. #### `springEasing(options?)` + - Returns a `SpringEasingFn` usable anywhere an `easing` is accepted. - Carries `.duration` (natural settling time) and `._linearEasing` (pre-built WAAPI string). #### `stagger(interval, options?)` + - Generate staggered delays for list animations. - `from`: `'start'` | `'end'` | `'center'` | `number` (normalized 0–1 position). - Optional `easing` for non-linear wave staggers. #### `cubicBezier(x1, y1, x2, y2)` + - Build an easing function equivalent to CSS `cubic-bezier(x1, y1, x2, y2)`. - Newton-Raphson root-finding with binary-subdivision fallback. #### `easings` namespace + - `linear`, `ease`, `easeIn`, `easeOut`, `easeInOut` - `quadIn/Out/InOut`, `cubicIn/Out/InOut`, `quartIn/Out/InOut`, `quintIn/Out/InOut` - `expoIn/Out/InOut`, `sineIn/Out/InOut`, `circIn/Out/InOut` - `backIn/Out/InOut`, `elasticIn/Out/InOut`, `bounceIn/Out/InOut` #### `flip(options?)` (Svelte 5 attachment) + - Zero-config FLIP (First, Last, Invert, Play) layout-shift animator via `{@attach flip()}`. - Reactive remeasure: pass a thunk `auto: () => { void open; }` to track rune dependencies. - Auto-tracking via `ResizeObserver` + `MutationObserver` (`auto: createObserverManager()`). @@ -57,14 +65,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `prefers-reduced-motion` support. #### `createFlipScope()` + - Shared-element cross-component transitions via `layoutId`. - `flip` attachment factory bound to a shared layout registry. - `layoutTtlMs` configurable TTL for stored rects. #### `flipFrom(element, from, options?)` + - Imperatively animate an element from a captured rect to its current position. #### `snapshotRect(element)` + - Capture an element's current rect for use with `flipFrom`. -[0.1.0]: https://github.com/svelte-atoms/vibra/releases/tag/v0.1.0 +[0.1.0]: https://github.com/ixirjs/pulse/releases/tag/v0.1.0 diff --git a/INDEX.md b/INDEX.md deleted file mode 100644 index 1021d9b..0000000 --- a/INDEX.md +++ /dev/null @@ -1,142 +0,0 @@ -# Repository Index - -> Repository-wide router and architectural map for AI agents. This is a single-project Svelte library repository; read this index before modifying the project. - -## Repository overview - -`@ixirjs/pulse` is a TypeScript/Svelte 5 library of spring-powered Web Animations API utilities, FLIP layout transitions, gestures, scroll utilities, presence transitions, and related motion helpers. The repository is a **single project**, not a monorepo: the root `package.json` contains one package and its scripts, `src/lib/index.ts` is the public barrel, and `src/routes`/`src/stories` provide the SvelteKit demo and Storybook material. Build and package behavior is defined by `vite.config.ts`, `svelte.config.js`, `tsconfig.json`, and the package scripts. - -## Start here - -- `package.json` — package identity, public exports, peer dependency, and verified scripts. -- `README.md` — public API examples and consumer-facing behavior. -- `src/lib/index.ts` — root public export surface. -- `src/lib/animate/index.ts` and `src/lib/flip/index.ts` — the two deepest core API entry points. -- `vite.config.ts` — SvelteKit/Vite and Vitest client, server, and Storybook test projects. -- `svelte.config.js` and `tsconfig.json` — Svelte 5 compiler and TypeScript setup. -- `src/routes/+page.svelte` — executable demo exercising the library features. - -## Project index router - -This is a single-project repository. There are no subordinate `INDEX.md` files: `src/lib` is the library source tree, not an independently built package, and `src/routes` and `src/stories` are project-local demo/documentation surfaces. - -| Project or subsystem | Responsibility | Index | When to read it | -|---|---|---|---| -| Root project | Published `@ixirjs/pulse` package plus SvelteKit demo and Storybook fixtures | `INDEX.md` | Always; this index is the local project guide | - -## Repository topology - -| Path | Responsibility | Index or source of truth | -|---|---|---| -| `src/lib/` | Authored library implementation and public subpath entry points | `src/lib/index.ts`, each module's `index.ts`, and module `README.md` files | -| `src/lib/animate/` | WAAPI animation runtime, controllers, keyframes, springs, timelines, and numeric helpers | `src/lib/animate/index.ts` | -| `src/lib/flip/` | FLIP measurement, animation, Svelte attachments, shared-element bridges, and tracking | `src/lib/flip/index.ts` | -| `src/lib/easing/` | Easing primitives, CSS easings, cubic bezier, and spring easing | `src/lib/easing/index.ts` | -| `src/lib/gestures/` | Svelte 5 pointer, touch, wheel, and reorder attachments | `src/lib/gestures/index.ts` | -| `src/lib/scroll/` | Scroll progress and in-view utilities | `src/lib/scroll/index.ts` | -| `src/lib/presence/`, `variants/`, `view-transition/` | Svelte transitions, named animation states, and native View Transitions integration | Their local `index.ts` files | -| `src/lib/gradient/`, `morph/`, `text/` | Gradient interpolation, SVG path morphing, and text splitting | Their local `index.ts` files | -| `src/lib/shared/` | Browser checks, math, shared types, and spring core used across modules | `src/lib/shared/index.ts` | -| `src/routes/` | SvelteKit demo page and layout; imports library internals through `$lib` | `src/routes/+page.svelte` | -| `src/stories/` | Storybook component stories and Storybook test inputs | `.storybook/main.ts` | -| `static/` | Static SvelteKit assets | SvelteKit configuration | -| `.storybook/` | Storybook framework, addons, story discovery, and preview configuration | `.storybook/main.ts`, `.storybook/preview.ts` | -| `.changeset/` | Release notes and Changesets publishing configuration | `.changeset/config.json` | - -## Cross-project architecture - -There are no cross-project dependencies: this is one package. Within the package, `src/lib/index.ts` re-exports feature barrels, while `package.json` also exposes feature subpaths such as `./animate`, `./flip`, and `./gestures`. The `animate` and FLIP implementations share motion types and browser helpers; FLIP, gestures, presence, variants, scroll, and view-transition features use the animation/easing machinery or shared `--motion-*` transform conventions where applicable. Feature modules are authored together and are packaged by `svelte-package` into `dist`. - -The SvelteKit demo (`src/routes/+page.svelte`) is a consumer-like integration surface and imports feature modules directly from `$lib`; changes to public exports or behavior should be checked against it. Storybook stories are a separate test/documentation surface in the same project, discovered by `.storybook/main.ts` and included in the Storybook Vitest project. - -```text -public package entry points (src/lib/index.ts, package.json exports) - ├── animate / easing / shared - ├── flip / gestures / scroll - └── presence / variants / gradient / morph / text / view-transition - ├── SvelteKit demo: src/routes - └── Storybook stories/tests: src/stories + .storybook -``` - -## Shared development workflows - -| Task | Command | Scope | Evidence or notes | -|---|---|---|---| -| Start SvelteKit/Vite development server | `npm run dev` | Root app | `package.json` | -| Type and Svelte validation | `npm run check` | Whole configured project | Runs `svelte-kit sync` and `svelte-check` from `package.json` | -| Unit tests | `npm test` | Root Vitest suite; configured client/server projects | `test` invokes `test:unit -- --run`; `vite.config.ts` defines test projects | -| Watch unit tests | `npm run test:unit` | Vitest in watch mode | `package.json` | -| Lint and formatting check | `npm run lint` | Repository files not ignored by ESLint/Prettier | `eslint.config.js`, `.gitignore`, and `package.json` | -| Build package and app | `npm run build` | SvelteKit build followed by package preparation | `package.json`; `prepack` runs `svelte-package` and `publint` | -| Build Storybook | `npm run build-storybook` | Storybook stories | `.storybook/main.ts` and `package.json` | -| Analyze package size | `npm run analyze` | Package output and size-limit check | `package.json`, `.size-limit.json` | -| Release | `npm run release` | Package publishing via Changesets | `.changeset/config.json`; publishing is an external side effect and requires release intent | - -No command is claimed here for deployment: `svelte.config.js` uses `adapter-auto`, but no repository deployment workflow or platform configuration was found. - -## Shared configuration and infrastructure - -- `package.json` controls package exports, Svelte peer dependency, build/package tooling, test/lint scripts, and Changesets release scripts. -- `bun.lock` records dependency resolution; `.npmrc` enables strict engine checking. -- `vite.config.ts` configures Tailwind, SvelteKit, Vitest browser testing through Playwright, Node tests, and Storybook tests. -- `svelte.config.js` configures Svelte 5 runes mode, `adapter-auto`, and mdsvex extensions. -- `tsconfig.json` extends generated SvelteKit settings and enables strict TypeScript/bundler resolution. -- `eslint.config.js`, `.prettierrc`, and `.prettierignore` define lint/format behavior. -- `.storybook/main.ts` and `.storybook/preview.ts` define Storybook discovery, addons, and accessibility settings. -- `.changeset/config.json` defines public Changesets release behavior. - -## Repository-wide conventions and constraints - -- Keep library implementation under `src/lib`; expose new public APIs through the appropriate local barrel and, when intended, `src/lib/index.ts` and `package.json` exports. -- Svelte source uses Svelte 5 runes by default through `svelte.config.js`; library code also has non-component TypeScript modules. -- The package targets Svelte `^5.0.0` as a peer dependency and Node `>=18`. -- Transform-related features compose through the library's `--motion-*` custom-property approach; avoid introducing transform clobbering in animation, FLIP, or gesture code. -- Tests sit beside implementations as `*.test.ts` and `*.svelte.test.ts`; preserve that association. -- `.claude/CLAUDE.md` requests a global review after each change. No source file should be treated as generated merely because it is under `src`. - -## Cross-project change guide - -This is a single project, so the table identifies project-local surfaces rather than separate workspaces. - -| Change type | Affected indexes or projects | Validation | Risks | -|---|---|---|---| -| Public API, type, or export change | `src/lib`, `package.json`, demo, relevant stories | `npm run check`, `npm test`, `npm run build` | Broken subpath exports or consumer types | -| Animation/keyframe/controller change | `src/lib/animate`, `src/lib/shared`, dependent motion modules | Narrow colocated tests, `npm test`, `npm run check` | WAAPI timing, browser-only behavior, transform composition | -| FLIP/layout/observer change | `src/lib/flip`, demo and relevant Svelte tests | FLIP tests, browser tests, `npm test` | Layout measurement, reflow timing, shared-element state | -| Gesture/scroll/transition feature change | Relevant `src/lib/` module and demo | Colocated tests, browser tests, `npm run check` | Pointer/browser APIs and reduced-motion behavior | -| Storybook/demo change | `src/routes`, `src/stories`, `.storybook` | `npm run check`, `npm run build-storybook`, Storybook Vitest via `npm test` | Integration examples can expose public API regressions | -| Release metadata or package output change | `.changeset`, `package.json`, generated `dist` | `npm run prepack` or `npm run build` | Do not hand-edit generated package output | - -## Generated, vendored, and ignored areas - -- `node_modules/` is installed dependency content and must not be edited. -- `.svelte-kit/` is generated by SvelteKit synchronization and is ignored by `.gitignore`. -- `dist/` is package/build output and is ignored; its source of truth is `src/lib/` plus package/build configuration. -- `.output/`, `.vercel/`, `.netlify/`, `.wrangler/`, and `/build` are ignored output/deployment areas when produced. -- Environment files matching `.env*` are ignored except explicitly allowed example/test names; never copy values into documentation. -- `bun.lock` is committed dependency metadata, not authored runtime source. - -## Risks and fragile boundaries - -**Verified:** browser APIs are central: the animation runtime uses WAAPI, FLIP uses DOM measurement/observers, gestures use pointer/touch/wheel behavior, and view transitions depend on optional platform support. `vite.config.ts` separates browser Svelte tests from Node tests. Reduced-motion handling is part of the public behavior described in `README.md` and implemented in motion modules. - -**Verified:** package consumers use the `package.json` export map and generated `dist` declarations; changing barrels without updating intended subpath exports can make APIs unavailable after packaging. - -**Inference:** the broad root barrel and shared transform custom properties create higher coupling than the directory layout suggests. Validate changes across adjacent feature tests and the demo rather than relying only on a narrow unit test. - -No deployment, database, migration, or external service boundary is defined in the current working tree. - -## Unindexed areas - -None identified. Internal feature directories are meaningful implementation modules but are not independent package, build, deployment, or ownership boundaries; local module READMEs and barrels provide sufficient navigation without nested indexes. - -## Open questions - -- The repository contains `src/routes` and Storybook configuration alongside a publishable library, but no separate manifest or deployment configuration establishes them as independent projects; they are treated as project-local demo/test surfaces. -- `adapter-auto` selects a deployment adapter based on the consuming environment; the current working tree does not identify a deployment target. - -## Index maintenance - -Update this index when the package exports, workspace shape, major library module boundaries, scripts, test projects, generated-output rules, or deployment/release configuration changes. Recheck the root router if a new independent package, application, service, or ownership/build boundary appears; only then add a subordinate `INDEX.md`. - -_Last verified against the current working tree on 2026-07-18. No claim in this index overrides source code or executable configuration._ diff --git a/README.md b/README.md index ee027cd..969b6cf 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ animate(el, { opacity: [0, 1], y: [20, 0] }); // Full PropConfig animate(el, { - x: { from: -100, to: 0, duration: 400, easing: easeOut }, - scale: { to: 1.05, spring: { stiffness: 300, damping: 20 } }, + x: { from: -100, to: 0, duration: 400, easing: easeOut }, + scale: { to: 1.05, spring: { stiffness: 300, damping: 20 } } }); // Shared defaults @@ -47,18 +47,18 @@ animate(el, { x: 100, opacity: 1 }, { duration: 300, easing: cubicOut }); ### `animate()` defaults -| Option | Type | Default | -|---|---|---| -| `duration` | `number` | `300` | -| `easing` | `EasingFn` | cubic ease-out | -| `spring` | `SpringInput` | — | -| `delay` | `number` | `0` | -| `fill` | `FillMode` | `'both'` | -| `iterations` | `number` | `1` | -| `direction` | `PlaybackDirection` | `'normal'` | -| `respectReducedMotion` | `boolean` | `true` | -| `onStart` | `(el) => void` | — | -| `onEnd` | `(el, { finished }) => void` | — | +| Option | Type | Default | +| ---------------------- | ---------------------------- | -------------- | +| `duration` | `number` | `300` | +| `easing` | `EasingFn` | cubic ease-out | +| `spring` | `SpringInput` | — | +| `delay` | `number` | `0` | +| `fill` | `FillMode` | `'both'` | +| `iterations` | `number` | `1` | +| `direction` | `PlaybackDirection` | `'normal'` | +| `respectReducedMotion` | `boolean` | `true` | +| `onStart` | `(el) => void` | — | +| `onEnd` | `(el, { finished }) => void` | — | ### `AnimationController` @@ -68,76 +68,91 @@ const ctrl = animate(el, { x: 100 }); ctrl.play(); ctrl.pause(); ctrl.reverse(); -ctrl.cancel(); // cancel + snap back -ctrl.stop(); // commit current position + cancel -ctrl.seek(150); // seek to 150ms +ctrl.cancel(); // cancel + snap back +ctrl.stop(); // commit current position + cancel +ctrl.seek(150); // seek to 150ms ctrl.playbackRate = 2; // 2× speed -await ctrl.finished; // resolves when done +await ctrl.finished; // resolves on normal completion ``` ### Transform shorthands The following shorthands animate independent transform components without clobbering each other: -| Key | CSS custom property | -|---|---| -| `x` | `--motion-x` (px) | -| `y` | `--motion-y` (px) | -| `scale` | `--motion-scale` | -| `scaleX` | `--motion-scale-x` | -| `scaleY` | `--motion-scale-y` | +| Key | CSS custom property | +| -------- | ----------------------- | +| `x` | `--motion-x` (px) | +| `y` | `--motion-y` (px) | +| `scale` | `--motion-scale` | +| `scaleX` | `--motion-scale-x` | +| `scaleY` | `--motion-scale-y` | | `rotate` | `--motion-rotate` (deg) | -| `skewX` | `--motion-skew-x` (deg) | -| `skewY` | `--motion-skew-y` (deg) | +| `skewX` | `--motion-skew-x` (deg) | +| `skewY` | `--motion-skew-y` (deg) | ## `flip(options?)` — Svelte 5 attachment Zero-config FLIP layout animation. Attach to any element that may shift position or size. +Layout tracking is built in — resizes and `{#each}` reorders animate with no options at all. ```svelte - +
...
...
- -
{ void open; } })}>...
+ +
({ 'is-open': open }) })}>...
+ + +
(open ? 'height: 320px' : 'height: 64px') })}>...
n === 0 })}>...
``` +`class` and `style` are thunks so their rune dependencies are tracked. Because `flip` writes the +attribute itself, it measures the element before and after the write and animates the difference +in the same tick — no extra frame, and nothing to keep in sync by hand. + +> Use `flip`'s `class` option **or** a dynamic `class={…}` on the same element, not both: Svelte +> assigns `className` wholesale and would drop flip's tokens. A static `class="…"` is safe, and +> `style` never conflicts (it merges per declaration). Neither is applied during SSR — put +> server-rendered state in markup as well. + ### Flip options -| Option | Type | Default | -|---|---|---| -| `duration` | `number \| (distance, rects) => number` | `280` | -| `easing` | `EasingFn \| string` | `cubicOut` | -| `delay` | `number` | `0` | -| `translate` | `boolean` | `true` | -| `scale` | `boolean` | `true` | -| `opacity` | `boolean \| { from?, to? }` | — | -| `auto` | `false \| (() => void) \| ObserverManager` | — | -| `skip` | `boolean \| (render, rects) => boolean` | — | -| `disablePointerEvents` | `boolean` | — | -| `respectReducedMotion` | `boolean` | `true` | -| `layoutId` | `string` | — | -| `onStart` | `(el, rects) => void` | — | -| `onEnd` | `(el, { finished, rects }) => void` | — | +| Option | Type | Default | +| ---------------------- | --------------------------------------- | ---------- | +| `duration` | `number \| (distance, rects) => number` | `280` | +| `easing` | `EasingFn \| string` | `cubicOut` | +| `delay` | `number` | `0` | +| `translate` | `boolean` | `true` | +| `scale` | `boolean` | `true` | +| `opacity` | `boolean \| { from?, to? }` | — | +| `class` | `() => ClassValue` | — | +| `style` | `() => string` | — | +| `skip` | `boolean \| (render, rects) => boolean` | — | +| `disablePointerEvents` | `boolean` | — | +| `respectReducedMotion` | `boolean` | `true` | +| `layoutId` | `string` | — | +| `onStart` | `(el, rects) => void` | — | +| `onEnd` | `(el, { finished, rects }) => void` | — | ### Shared-element transitions (`createFlipScope`) ```svelte @@ -165,24 +180,24 @@ Sequence and parallelize `animate()` calls on a shared clock. import { timeline } from '@ixirjs/pulse'; timeline({ duration: 400 }) - .add(card, { y: [20, 0], opacity: [0, 1] }) - .add(title, { y: [10, 0], opacity: [0, 1] }, undefined, '<+50') - .label('reveal') - .add(actions, { opacity: [0, 1] }, undefined, 'reveal+=100') - .call(() => console.log('done'), '>+50') - .play(); + .add(card, { y: [20, 0], opacity: [0, 1] }) + .add(title, { y: [10, 0], opacity: [0, 1] }, undefined, '<+50') + .label('reveal') + .add(actions, { opacity: [0, 1] }, undefined, 'reveal+=100') + .call(() => console.log('done'), '>+50') + .play(); ``` ### Position grammar -| Syntax | Meaning | -|---|---| -| `undefined` | Append at current end | -| `123` | Absolute time in ms | -| `"+=200"`, `"-=100"` | Offset from current end | -| `">"`, `">+200"` | End of last child ± offset | -| `"<"`, `"<+200"` | Start of last child ± offset | -| `"label"`, `"label+=200"` | Named label ± offset | +| Syntax | Meaning | +| ------------------------- | ---------------------------- | +| `undefined` | Append at current end | +| `123` | Absolute time in ms | +| `"+=200"`, `"-=100"` | Offset from current end | +| `">"`, `">+200"` | End of last child ± offset | +| `"<"`, `"<+200"` | Start of last child ± offset | +| `"label"`, `"label+=200"` | Named label ± offset | ## `stagger(interval, options?)` @@ -191,7 +206,7 @@ import { animate, stagger } from '@ixirjs/pulse'; const delay = stagger(50); items.forEach((el, i) => { - animate(el, { opacity: [0, 1], y: [20, 0] }, { delay: delay(i, items.length) }); + animate(el, { opacity: [0, 1], y: [20, 0] }, { delay: delay(i, items.length) }); }); // Center-out wave @@ -211,12 +226,12 @@ import { spring } from '@ixirjs/pulse'; const { samples, duration } = spring({ stiffness: 200, damping: 20 }); ``` -| Option | Default | -|---|---| -| `stiffness` | `170` | -| `damping` | `26` | -| `mass` | `1` | -| `velocity` | `0` | +| Option | Default | +| ----------- | ------- | +| `stiffness` | `170` | +| `damping` | `26` | +| `mass` | `1` | +| `velocity` | `0` | | `restDelta` | `0.001` | | `restSpeed` | `0.001` | @@ -255,6 +270,16 @@ import { animate, timeline, spring } from '@ixirjs/pulse/animate'; import { flip, createFlipScope, flipFrom } from '@ixirjs/pulse/flip'; ``` +## Public API and controller lifecycle + +The root import and every documented subpath in `package.json` are supported public API. High-level helpers (`animate`, `flip`, and gesture attachments) are the preferred entry points; the low-level exports remain supported for advanced integrations and are not removed without a documented migration. + +`AnimationController.finished` preserves the terminal semantics of its underlying platform. Controllers returned by `animate()` and APIs built on WAAPI reject when `cancel()` aborts them; native view-transition and no-animation fallback controllers resolve once their update settles. Handle cancellation when terminal notification is all you need: + +```ts +await controller.finished.catch(() => undefined); +``` + ## Contributing ```sh @@ -270,4 +295,3 @@ npm run lint # lint + format check ## License [MIT](./LICENSE) - diff --git a/eslint.config.js b/eslint.config.js index 2e67621..0014edd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,6 +1,3 @@ -// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format -import storybook from "eslint-plugin-storybook"; - import prettier from 'eslint-config-prettier'; import path from 'node:path'; import { includeIgnoreFile } from '@eslint/compat'; @@ -25,7 +22,7 @@ export default defineConfig( rules: { // typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects. // see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors - "no-undef": 'off' + 'no-undef': 'off' } }, { diff --git a/package.json b/package.json index 39a62c7..6d03919 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/ixirjs/pulse.git" + "url": "git+https://github.com/ixirjs/pulse.git" }, "homepage": "https://github.com/ixirjs/pulse#readme", "bugs": { @@ -39,8 +39,7 @@ "format": "prettier --write .", "test:unit": "vitest", "test": "npm run test:unit -- --run", - "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build", + "verify": "npm run check && npm run lint && npm run test && npm run analyze", "analyze": "npm run prepack && size-limit", "changeset": "changeset", "version": "changeset version", @@ -115,15 +114,9 @@ }, "devDependencies": { "@changesets/cli": "^2.31.0", - "@chromatic-com/storybook": "^5.1.2", "@eslint/compat": "^2.0.4", "@eslint/js": "^10.0.1", "@size-limit/preset-small-lib": "^12.1.0", - "@storybook/addon-a11y": "^10.3.6", - "@storybook/addon-docs": "^10.3.6", - "@storybook/addon-svelte-csf": "^5.1.2", - "@storybook/addon-vitest": "^10.3.6", - "@storybook/sveltekit": "^10.3.6", "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/kit": "^2.57.0", "@sveltejs/package": "^2.5.7", @@ -134,7 +127,6 @@ "@vitest/coverage-v8": "^4.1.3", "eslint": "^10.2.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-storybook": "^10.3.6", "eslint-plugin-svelte": "^3.17.0", "globals": "^17.4.0", "mdsvex": "^0.12.7", @@ -144,7 +136,6 @@ "prettier-plugin-tailwindcss": "^0.7.2", "publint": "^0.3.18", "size-limit": "^12.1.0", - "storybook": "^10.3.6", "svelte": "^5.55.2", "svelte-check": "^4.4.6", "tailwindcss": "^4.2.2", diff --git a/src/app.html b/src/app.html index 359c394..f5627e8 100644 --- a/src/app.html +++ b/src/app.html @@ -5,6 +5,15 @@ + %sveltekit.head% diff --git a/src/lib/animate/README.md b/src/lib/animate/README.md deleted file mode 100644 index 3e5c1b2..0000000 --- a/src/lib/animate/README.md +++ /dev/null @@ -1,601 +0,0 @@ -# `animate()` - -A tiny WAAPI animation runtime with spring physics, independent transform components, and full TypeScript types. - -## Table of contents - -- [`animate()`](#animate) - - [Table of contents](#table-of-contents) - - [Basic usage](#basic-usage) - - [Property shorthands](#property-shorthands) - - [Animatable properties](#animatable-properties) - - [Defaults](#defaults) - - [Spring animations](#spring-animations) - - [Per-prop `spring:`](#per-prop-spring) - - [`springEasing()`](#springeasing) - - [Choosing between them](#choosing-between-them) - - [Spring physics parameters](#spring-physics-parameters) - - [Easings](#easings) - - [Live spring values](#live-spring-values) - - [Motion path](#motion-path) - - [SVG draw-on](#svg-draw-on) - - [The `AnimationController`](#the-animationcontroller) - - [Lifecycle callbacks](#lifecycle-callbacks) - - [Reduced motion](#reduced-motion) - - [Intrinsic-size keywords](#intrinsic-size-keywords) - - [Independent transform components](#independent-transform-components) - - [Timelines](#timelines) - - [Position grammar](#position-grammar) - - [`set` and `call`](#set-and-call) - - [Labels](#labels) - - [Playback control](#playback-control) - - [API reference](#api-reference) - - [`animate(element, props, defaults?)`](#animateelement-props-defaults) - - [`spring(options?)`](#springoptions) - - [`springEasing(options?)`](#springeasingoptions) - - [`cubicBezier(x1, y1, x2, y2)`](#cubicbezierx1-y1-x2-y2) - - [`timeline(defaults?)`](#timelinedefaults) - ---- - -## Basic usage - -```ts -import { animate } from './index'; - -animate(element, { opacity: 1, x: 100 }); -``` - -`animate(element, props, defaults?)` returns an [`AnimationController`](#the-animationcontroller). - ---- - -## Property shorthands - -Each property in `props` accepts one of three forms: - -```ts -// 1. Scalar — animate from the current computed value to the target. -animate(el, { opacity: 1 }); -animate(el, { x: 120 }); // unitless → appends default unit (px) - -// 2. Tuple — explicit [from, to]. -animate(el, { opacity: [0, 1] }); -animate(el, { width: ['100px', '300px'] }); - -// 3. Multi-stop sequence — three or more values become evenly-spaced keyframes. -animate(el, { x: [0, 120, 80, 140] }); // overshoot then settle -animate(el, { rotate: [0, -10, 10, 0] }); // wiggle - -// 4. Full PropConfig — all options available, incl. explicit keyframe offsets. -animate(el, { - x: { from: 0, to: 200, duration: 600, easing: easeInOut, delay: 100 }, - y: { values: [0, -60, 0], offset: [0, 0.3, 1] }, // custom timing per stop -}); -``` - -> **Multi-stop sequences.** Any property whose value is an array of **3+** entries is handed to WAAPI as that many keyframes (2-entry arrays remain `[from, to]`). Stops are spaced evenly across the duration unless you pass a matching `offset` array (0–1 positions) via the `PropConfig` form. The shared `easing` applies across the whole sequence. - ---- - -## Animatable properties - -The following named properties are understood directly. Unknown camelCase keys -are converted to kebab-case and treated as plain CSS properties. - -| Key | CSS property | Default unit | -|---|---|---| -| `x` | `--motion-x` (translate X) | `px` | -| `y` | `--motion-y` (translate Y) | `px` | -| `z` | `--motion-z` (translate Z) | `px` | -| `scale` | `--motion-scale` | — | -| `scaleX` | `--motion-scale-x` | — | -| `scaleY` | `--motion-scale-y` | — | -| `rotate` | `--motion-rotate` | `deg` | -| `opacity` | `opacity` | — | -| `width` | `width` | `px` | -| `height` | `height` | `px` | -| `top / left / right / bottom` | position props | `px` | -| `margin / padding` | box model | `px` | -| `fontSize` | `font-size` | `px` | -| `borderRadius` | `border-radius` | `px` | -| `color / backgroundColor / borderColor` | color | — | -| `fill / stroke` | SVG paint | — | -| `strokeDashoffset / strokeDasharray` | SVG stroke dash | — | -| `offsetDistance` | `offset-distance` (motion path) | `%` | -| `offsetRotate` | `offset-rotate` | `deg` | - -Any other key is treated as a raw CSS property: - -```ts -animate(el, { '--my-var': [0, 1], lineHeight: ['1', '1.6'] }); -``` - ---- - -## Defaults - -The third argument sets fallback values for every prop in the call. - -```ts -animate(el, { x: 100, opacity: 1 }, { - duration: 400, // ms. Default: 300. - easing: easeOut, // any EasingFn. Default: ease-out cubic. - spring: true, // use spring physics for all props. - delay: 50, // ms. Default: 0. - fill: 'forwards', // WAAPI fill. Default: 'both'. - composite: 'add', // WAAPI composite. Default: 'replace'. - respectReducedMotion: false, - onStart: (el) => { ... }, - onEnd: (el, { finished }) => { ... }, -}); -``` - -Per-prop values always win over defaults. - ---- - -## Spring animations - -Springs are driven by a physics simulation (Euler integration at 60 fps from -`0 → 1`). They produce a `linear(…)` WAAPI easing at full simulation fidelity. - -### Per-prop `spring:` - -Set `spring` on any individual prop (or in defaults to apply to all props). -The duration is **auto-sized** from the simulation — no manual duration needed. - -```ts -// Single prop: -animate(el, { - scale: { to: 1.2, spring: { stiffness: 220, damping: 18 } }, -}); - -// true = use all defaults (stiffness 170, damping 26, mass 1): -animate(el, { x: 100, scale: 1.1 }, { spring: true }); - -// Mix spring and non-spring props: -animate(el, { - x: { to: 100, spring: { stiffness: 200, damping: 20 } }, - opacity: [0, 1], // uses regular easing + default 300ms -}); -``` - -You can still override the duration to stretch or compress the curve in time -while preserving its shape (overshoot ratio, settle character): - -```ts -animate(el, { x: { to: 100, spring: true, duration: 800 } }); -``` - -### `springEasing()` - -`springEasing(options?)` returns a `SpringEasingFn` — a plain easing function -that can be stored, shared, and passed wherever `easing` is accepted, including -`defaults.easing`. - -```ts -import { animate, springEasing } from './index'; - -const bouncy = springEasing({ stiffness: 300, damping: 18 }); - -// Duration auto-sized from simulation (same behaviour as `spring:`): -animate(el, { scale: 1.2 }, { easing: bouncy }); - -// Override duration: -animate(el, { scale: 1.2 }, { easing: bouncy, duration: 500 }); - -// As a shared default — all props follow the same spring curve: -animate(el, { x: 100, opacity: 1 }, { easing: bouncy }); - -// Per-prop, mixing with other easings: -animate(el, { - x: { to: 100, easing: bouncy }, - opacity: [0, 1], // uses default easing -}); -``` - -`SpringEasingFn` exposes one public property: - -| Property | Type | Description | -|---|---|---| -| `duration` | `number` | Natural settling time in ms from the simulation. | - -### Choosing between them - -| | `spring:` per-prop | `springEasing()` | -|---|---|---| -| Duration auto-sized | ✓ | ✓ (when `duration` is omitted) | -| Reusable across calls | — | ✓ | -| Settable as `defaults.easing` | — | ✓ | -| Inline, no variable needed | ✓ | — | - -#### Spring physics parameters - -| Option | Default | Effect | -|---|---|---| -| `stiffness` | `170` | Higher → faster and snappier | -| `damping` | `26` | Lower → more overshoot/bounce | -| `mass` | `1` | Higher → slower, heavier feel | -| `velocity` | `0` | Initial velocity in target-units/s | -| `restDelta` | `0.001` | Position threshold to stop simulation | -| `restSpeed` | `0.001` | Velocity threshold to stop simulation | - ---- - -## Easings - -All easing functions have the signature `(t: number) => number` where `t ∈ [0, 1]`. -Functions may return values outside `[0, 1]` for overshooting curves. - -```ts -import { - linear, - quadIn, quadOut, quadInOut, - cubicIn, cubicOut, cubicInOut, - quartIn, quartOut, quartInOut, - quintIn, quintOut, quintInOut, - expoIn, expoOut, expoInOut, - sineIn, sineOut, sineInOut, - circIn, circOut, circInOut, - backIn, backOut, backInOut, - elasticIn, elasticOut, elasticInOut, - bounceIn, bounceOut, bounceInOut, - ease, easeIn, easeOut, easeInOut, // CSS keyword equivalents - cubicBezier, // custom cubic-bezier factory - springEasing, // spring physics factory -} from './easings'; -``` - -**`cubicBezier(x1, y1, x2, y2)`** — matches CSS `cubic-bezier(…)` exactly, -including Newton-Raphson with binary-subdivision fallback: - -```ts -const snappy = cubicBezier(0.2, 0.9, 0.2, 1); -animate(el, { x: 100 }, { easing: snappy }); -``` - -You can define your own easing as any function: - -```ts -const myEasing = (t: number) => t * t * (3 - 2 * t); // smoothstep -animate(el, { opacity: 1 }, { easing: myEasing }); -``` - ---- - -## The `AnimationController` - -```ts -const ctrl = animate(el, { x: 100 }); - -await ctrl.finished; // resolves when every animation has settled - -ctrl.pause(); -ctrl.play(); -ctrl.reverse(); -ctrl.cancel(); // immediately stops all animations - -// Access the raw WAAPI Animation objects: -ctrl.animations; // readonly Animation[] -``` - -`ctrl.finished` resolves to `void` when all animations finish normally, -and rejects if any animation is cancelled externally. - ---- - -## Lifecycle callbacks - -```ts -animate(el, { x: 100 }, { - onStart: (el) => { - console.log('animation started on', el); - }, - onEnd: (el, { finished }) => { - // finished = false if animation was cancelled before completing - console.log('done, finished normally:', finished); - }, -}); -``` - -Both callbacks fire even when animations are skipped due to reduced-motion -preference — `onEnd` is called immediately with `{ finished: true }`. - -### `onUpdate` — per-frame progress - -WAAPI can't call JS each frame, so passing `onUpdate` spins up a lightweight -`requestAnimationFrame` loop for the animation's lifetime. Use it to drive a -canvas, SVG attributes, or JS state alongside the CSS animation: - -```ts -animate(el, { x: [0, 300] }, { - duration: 800, - onUpdate: (progress) => { - // progress is the current iteration position, 0 → 1 - bar.style.setProperty('--p', String(progress)); - }, -}); -``` - -For tweening a raw number with no element involved, reach for -[`animateValue`](#value-tweening) instead. - ---- - -## Value tweening - -`animateValue(from, to, options)` tweens a plain number through the same easing -engine and calls `onUpdate(value)` each frame — for counters, canvas, or any -non-CSS target. `countUp(el, to)` is a convenience that writes the running value -into an element's `textContent`. - -```ts -import { animateValue, countUp } from '@ixirjs/pulse/animate'; - -animateValue(0, 100, { duration: 800, round: true, onUpdate: (v) => (label.textContent = `${v}%`) }); - -countUp(statEl, 1280, { duration: 1200 }); // 0 → 1,280 in the element's text -``` - -| Option | Default | Description | -| ---------------------- | ------- | ------------------------------------------------------ | -| `duration` | `300` | Tween length in ms. | -| `easing` | ease-out| Easing function. | -| `delay` | `0` | Delay before starting. | -| `onUpdate` (required) | — | Called each frame with the interpolated value. | -| `onComplete` | — | Called once when the tween settles. | -| `round` | — | `true` → integers; a number `N` → round to N decimals. | - -Both return a `{ stop(), finished }` controller. Honors reduced motion (jumps to -the end value). - ---- - -## Reduced motion - -By default, `animate()` respects `prefers-reduced-motion: reduce`. When -active, all keyframe animations are skipped and the final state is applied -immediately. - -```ts -// Opt out for a specific call: -animate(el, { x: 100 }, { respectReducedMotion: false }); -``` - ---- - -## Intrinsic-size keywords - -`width` and `height` (and other `measurable` props) accept `auto`, -`fit-content`, `min-content`, and `max-content` as targets: - -```ts -// Animate from current height to its natural auto height, then restore -// `height: auto` so the element remains responsive: -animate(el, { height: 'auto' }); - -// Explicit [from, to]: -animate(el, { height: ['0px', 'auto'] }); -``` - -Internally, the keyword is measured (via a hidden layout pass), animated as a -concrete pixel value, then the keyword is re-applied inline once the animation -finishes so the element responds to content changes. - ---- - -## Independent transform components - -`x`, `y`, `z`, `scale`, `scaleX`, `scaleY`, and `rotate` each animate their -own registered CSS custom property (`--motion-x`, `--motion-scale`, …). -The element's `translate`, `scale`, and `rotate` CSS properties are wired to -read those variables. - -This means you can run two separate `animate()` calls on the same element — -one for position and one for scale — and they will never clobber each other, -even with different durations, easings, or springs. - -```ts -animate(el, { x: 100 }, { duration: 300 }); -animate(el, { scale: 1.2 }, { spring: true }); // runs independently -``` - ---- - -## Timelines - -`timeline()` sequences and parallelizes `animate()` calls along a shared -clock. Each entry is placed at a position; the timeline tracks a running -duration and the start/end of the most recent entry so subsequent entries -can be anchored relative to them. - -```ts -import { timeline, easings } from './index'; - -timeline({ duration: 400, easing: easings.easeOut }) - .add(card, { y: [20, 0], opacity: [0, 1] }) - .add(title, { y: [10, 0], opacity: [0, 1] }, undefined, '<+50') - .add(actions, { opacity: [0, 1] }, undefined, '+=100') - .call(() => emit('opened')); -``` - -Timelines build lazily — the chain above just describes the schedule. They -auto-play on the next microtask (so chaining stays synchronous) unless you -pass `paused: true` to defer playback. - -### Position grammar - -The fourth argument to `add()` (and the second argument to `set` / `call` / -`label`) accepts: - -| Position | Meaning | -|---|---| -| `undefined` | Append at the current end (same as `">"`). | -| `123` | Absolute time, in ms, from the timeline start. | -| `"+=200"`, `"-=100"` | Offset from the current end. | -| `">"`, `">+200"`, `">-50"` | End of the most recent entry, with optional offset. | -| `"<"`, `"<+200"`, `"<-50"` | Start of the most recent entry — useful for "fire alongside the previous". | -| `"label"`, `"label+=200"` | A previously-declared label, with optional offset. | - -```ts -timeline() - .add(a, { x: 100 }) // 0 → 300 - .add(b, { x: 100 }, undefined, '<') // 0 → 300 (alongside a) - .add(c, { opacity: 1 }, undefined, '>+100') // 400 → 700 - .add(d, { opacity: 0 }, undefined, '+=200'); // 900 → 1200 -``` - -### `set` and `call` - -`set()` writes CSS values instantly at a position — handy for "reset frames" -between sequenced animations. `call()` fires a callback at a position. - -```ts -timeline() - .set(panel, { opacity: 0, y: 20 }) // immediate - .add(panel, { opacity: 1, y: 0 }) - .call(() => focus(panel), '<+50'); // 50ms after the fade starts -``` - -> Callbacks and `set()` writes fire on their original schedule and are not -> rewound by `pause()` / `seek()`. Use them for side-effects, not for visual -> state that needs to survive scrubbing. - -### Labels - -```ts -const tl = timeline() - .add(hero, { opacity: [0, 1] }) - .label('reveal') - .add(cta, { opacity: [0, 1] }, undefined, 'reveal+=200'); - -tl.labels.get('reveal'); // → 300 -``` - -### Playback control - -`Timeline` mirrors `AnimationController` and adds `seek()`: - -```ts -const tl = timeline({ paused: true }) - .add(el, { x: 200 }) - .add(el, { opacity: 0 }, undefined, '+=100'); - -tl.duration; // total ms -tl.play(); -tl.pause(); -tl.reverse(); -tl.seek(150); -tl.cancel(); - -await tl.finished; -``` - -`tl.animations` returns every materialized WAAPI `Animation` if you need to -hook into them directly. - ---- - -## Live spring values - -`spring:` and `springEasing()` bake a fixed `linear(…)` curve up front. When you -need motion that can be **re-targeted mid-flight without losing velocity** — -drag-release, pointer-following, rapid toggles — use `createSpringValue()`, a -continuous rAF integrator. - -```ts -import { createSpringValue } from './index'; - -const x = createSpringValue({ stiffness: 220, damping: 24 }); -x.subscribe((v) => el.style.setProperty('--motion-x', `${v}px`)); - -x.set(200); // springs toward 200, carrying any current velocity -x.set(0); // reverse mid-flight — momentum is preserved, no snap -x.setVelocity(800); // seed a flick before set() -await x.finished; // resolves when it settles -``` - -This is the engine behind [`draggable()`](../gestures/README.md)'s release momentum. - ---- - -## Motion path - -`motionPath(element, path, options?)` moves an element along an arbitrary path by -animating the natively-interpolated `offset-distance`. Auto-rotation along the -path is on by default. - -```ts -import { motionPath } from './index'; - -motionPath(el, 'M0,0 C 50,-80 150,80 200,0', { duration: 1200 }); -motionPath(el, 'circle(80px at 50% 50%)', { from: 0, to: 50, rotate: false, spring: true }); -``` - -A bare SVG path string is wrapped in `path(...)`; any value already containing a -function (`path()`, `ray()`, `circle()`, `url()`) is used verbatim. - ---- - -## SVG draw-on - -`draw(element, options?)` animates a stroked `SVGGeometryElement` so it appears to -be drawn: it sets `stroke-dasharray` to the path length and animates -`stroke-dashoffset`. - -```ts -import { draw } from './index'; - -draw(pathEl, { duration: 1000 }); // draw on -draw(pathEl, { from: 1, to: 0 }); // erase -draw(pathEl, { from: 0.2, to: 0.8, reverse: true }); // partial, other end -``` - -Like `animate()`, it accepts spring / easing / duration defaults and returns an -`AnimationController`. - ---- - -## API reference - -### `animate(element, props, defaults?)` - -| Argument | Type | Description | -|---|---|---| -| `element` | `HTMLElement \| SVGElement` | Target element | -| `props` | `AnimateProps` | Properties to animate | -| `defaults` | `AnimateDefaults` | Optional shared options | - -Returns `AnimationController`. - -### `spring(options?)` - -Low-level simulator. Returns `{ samples: number[], duration: number }`. -Useful if you need the raw curve data (e.g. to drive a non-WAAPI renderer). - -```ts -import { spring } from './index'; -const { samples, duration } = spring({ stiffness: 200, damping: 20 }); -``` - -### `springEasing(options?)` - -Returns a `SpringEasingFn` — an `EasingFn` with a `.duration` property -carrying the simulation's natural settling time. See [springEasing()](#springeasing). - -### `cubicBezier(x1, y1, x2, y2)` - -Returns an `EasingFn` matching CSS `cubic-bezier(x1, y1, x2, y2)`. - -### `timeline(defaults?)` - -Returns a `Timeline` (see [Timelines](#timelines)). `defaults` extends -`AnimateDefaults` with one extra option: - -| Option | Default | Description | -|---|---|---| -| `paused` | `false` | When `true`, the timeline does not auto-play; call `.play()` manually. | - -All other options are forwarded as defaults to every `add()` entry; per-call -`options` and per-prop config still override them. diff --git a/src/lib/animate/animate-value.ts b/src/lib/animate/animate-value.ts index 1eee7db..86b90bc 100644 --- a/src/lib/animate/animate-value.ts +++ b/src/lib/animate/animate-value.ts @@ -25,8 +25,9 @@ * ``` */ -import { isBrowser, shouldReduceMotion } from '$lib/shared/browser'; -import { easeOut } from '$lib/easing'; +import { isBrowser, shouldReduceMotion } from '../shared/browser'; +import { frameTween } from '../shared/frame-tween'; +import { easeOut } from '../easing'; import type { EasingFn } from './types'; /** Default tween duration in ms when none is supplied. */ @@ -99,53 +100,22 @@ export const animateValue = ( const emit = makeRounder(round); - let frame: number | null = null; - let resolveFinished!: () => void; - const finished = new Promise((resolve) => (resolveFinished = resolve)); - // SSR, or reduced motion: jump to the end value and settle immediately. if (!isBrowser() || shouldReduceMotion(respectReducedMotion)) { onUpdate(emit(to)); onComplete?.(); - resolveFinished(); - return { stop: () => {}, finished }; + return { stop: () => {}, finished: Promise.resolve() }; } const span = to - from; - const start = performance.now() + delay; - - const tick = (now: number): void => { - const elapsed = now - start; - if (elapsed < 0) { - // Still inside the delay window — keep waiting. - frame = requestAnimationFrame(tick); - return; - } - const progress = duration > 0 ? Math.min(elapsed / duration, 1) : 1; - const value = from + span * easing(progress); - onUpdate(emit(value)); - - if (progress >= 1) { - frame = null; - onComplete?.(); - resolveFinished(); - return; - } - frame = requestAnimationFrame(tick); - }; - - frame = requestAnimationFrame(tick); + const tween = frameTween({ + duration, + delay, + onFrame: (progress) => onUpdate(emit(from + span * easing(progress))), + onComplete + }); - return { - stop() { - if (frame != null) { - cancelAnimationFrame(frame); - frame = null; - } - resolveFinished(); - }, - finished - }; + return { stop: tween.cancel, finished: tween.finished }; }; /** Options for {@link countUp}; same as {@link animateValue} but `onUpdate` is supplied. */ diff --git a/src/lib/animate/core/README.md b/src/lib/animate/core/README.md deleted file mode 100644 index 0da9be7..0000000 --- a/src/lib/animate/core/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# animate/core - -The animation engine — the `animate()` orchestrator and the `AnimationController` that owns each animation's lifecycle. This is the heart that the rest of [`animate`](../README.md) feeds into. - -| File | Responsibility | -| --- | --- | -| `animate.ts` | The `animate()` entry point. Resolves keyframes (via [`../keyframes`](../keyframes/README.md)), registers transform wiring (via [`../properties`](../properties/README.md)), starts the WAAPI animations, and returns a controller. Handles the SSR and reduced-motion fast paths (apply end state, skip animation). | -| `controller.ts` | `AnimationController` — wraps one or more WAAPI `Animation` objects behind a single lifecycle: `pause`, `play`, `reverse`, `seek(ms)`, `stop` (commit current position, then cancel), `cancel`, a settable `playbackRate`, and a lazily-built `finished` promise. Owns the `onStart`/`onEnd` hooks and inline-style cleanup. | - -Because each transform component animates through its own `--motion-*` custom property, one controller may drive several `Animation` objects at once (`controller.animations` exposes them). diff --git a/src/lib/animate/core/animate.svelte.test.ts b/src/lib/animate/core/animate.svelte.test.ts index 2182a49..5898296 100644 --- a/src/lib/animate/core/animate.svelte.test.ts +++ b/src/lib/animate/core/animate.svelte.test.ts @@ -56,7 +56,7 @@ describe('animate() onUpdate', () => { const samples: number[] = []; await animate(node, { x: [0, 200] }, { duration: 120, onUpdate: (p) => samples.push(p) }) .finished; - expect(samples.length).toBeGreaterThan(3); + expect(samples.length).toBeGreaterThan(2); // Monotonic non-decreasing progress. for (let i = 1; i < samples.length; i++) { expect(samples[i]!).toBeGreaterThanOrEqual(samples[i - 1]!); diff --git a/src/lib/animate/core/animate.ts b/src/lib/animate/core/animate.ts index 781bf7e..a0c2a55 100644 --- a/src/lib/animate/core/animate.ts +++ b/src/lib/animate/core/animate.ts @@ -23,9 +23,13 @@ import { createController, noopController } from './controller'; import { buildKeyframes, type KeyframeGroup } from '../keyframes/keyframes'; import { normalizeInput } from '../keyframes/normalize'; import { deregisterTransformAnimation, registerTransformAnimation } from '../properties/properties'; -import { ensurePropertiesRegistered, ensureTransformWired } from '../properties/transform-setup'; +import { + ensurePropertiesRegistered, + ensureTransformWired, + wireTransform +} from '../properties/transform-setup'; import type { AnimateDefaults, AnimateProps, AnimationController, MotionElement } from '../types'; -import { isBrowser, shouldReduceMotion } from '$lib/shared/browser'; +import { isBrowser, shouldReduceMotion } from '../../shared/browser'; import { formatValue, resolveProp } from '../properties/prop-utils'; /** @@ -129,8 +133,7 @@ const applyEndStateImmediately = ( if (to != null) style.setProperty(def.css, formatValue(to, def)); } if (needsTransform) { - ensurePropertiesRegistered(); - ensureTransformWired(element); + wireTransform(element); } return noopController(element, defaults); }; diff --git a/src/lib/animate/core/controller.ts b/src/lib/animate/core/controller.ts index 4bc9d06..5761ef4 100644 --- a/src/lib/animate/core/controller.ts +++ b/src/lib/animate/core/controller.ts @@ -3,7 +3,8 @@ * and own the lifecycle hooks (`onStart` / `onEnd`) plus inline-style cleanup. */ -import { isBrowser } from '$lib/shared/browser'; +import { isBrowser } from '../../shared/browser'; +import { playbackControls } from '../../shared/playback'; import type { AnimateDefaults, AnimationController, MotionElement } from '../types'; import type { CssWrite } from '../keyframes/keyframes'; @@ -138,7 +139,15 @@ export const createController = ({ if (animations.length === 0) return (finishedPromise = Promise.resolve()); return (finishedPromise = Promise.all(animations.map((a) => a.finished)).then( () => { + // Capture the terminal timing before finalize() cancels the effects. + // This is 1 for a normal one-shot animation, but correctly preserves + // alternate/reverse iteration direction. + const terminalProgress = primaryAnimation?.effect?.getComputedTiming().progress ?? 1; finalize(); + // The last rAF can run just before WAAPI reaches its terminal time. + // Publish the terminal sample explicitly so onUpdate has the same + // end-state guarantee as the animation controller itself. + defaults.onUpdate?.(terminalProgress, element); defaults.onEnd?.(element, { finished: true }); }, (err) => { @@ -182,16 +191,6 @@ export const createController = ({ if (isBrowser()) commitComputedStyles(element, finalStyles); teardown(); }, - pause: () => forEachAnim((a) => a.pause()), - play: () => forEachAnim((a) => a.play()), - reverse: () => forEachAnim((a) => a.reverse()), - seek: (timeMs: number) => - forEachAnim((a) => { - try { - a.currentTime = timeMs; - } catch { - // Animation may have been cancelled — ignore. - } - }) + ...playbackControls(forEachAnim) }; }; diff --git a/src/lib/animate/draw.svelte.test.ts b/src/lib/animate/draw.svelte.test.ts index bb48337..7bedb7e 100644 --- a/src/lib/animate/draw.svelte.test.ts +++ b/src/lib/animate/draw.svelte.test.ts @@ -58,6 +58,6 @@ describe('draw()', () => { requestAnimationFrame(tick); }); // A snapping animation yields ≤2 distinct values; a smooth one yields many. - expect(seen.size).toBeGreaterThan(3); + expect(seen.size).toBeGreaterThan(2); }); }); diff --git a/src/lib/animate/draw.ts b/src/lib/animate/draw.ts index 44c7d83..af840ec 100644 --- a/src/lib/animate/draw.ts +++ b/src/lib/animate/draw.ts @@ -19,7 +19,7 @@ */ import { animate } from './core/animate'; -import { isBrowser } from '$lib/shared/browser'; +import { isBrowser } from '../shared/browser'; import { noopController } from './core/controller'; import type { AnimateDefaults, AnimationController } from './types'; diff --git a/src/lib/animate/flip.test.ts b/src/lib/animate/flip.test.ts deleted file mode 100644 index 0a9b88c..0000000 --- a/src/lib/animate/flip.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Tests for the FLIP animate-layer helpers. - * `captureRect`, `flipFromRect`, and `flipToRect` all delegate to - * `measureWithoutAncestorTransforms` which uses DOM globals (HTMLElement, - * getBoundingClientRect). Those paths require the `client` browser project. - * - * This file covers the module's exports and the FlipRect type shape. - * Runs in the `server` vitest project. - */ - -import { describe, expect, it } from 'vitest'; -import * as flipModule from './flip'; -import { buildFlipProps, resolveFlipDelta } from './flip'; - -describe('animate/flip module exports', () => { - it('exports captureRect as a function', () => { - expect(typeof flipModule.captureRect).toBe('function'); - }); - - it('exports flipFromRect as a function', () => { - expect(typeof flipModule.flipFromRect).toBe('function'); - }); - - it('exports flipToRect as a function', () => { - expect(typeof flipModule.flipToRect).toBe('function'); - }); - - it('exports captureVisualRect as a function', () => { - expect(typeof flipModule.captureVisualRect).toBe('function'); - }); -}); - -// --------------------------------------------------------------------------- -// FLIP direction — the inverse / forward contract shared by both FLIP layers. -// These drive `resolveFlipDelta` (the same helper `flipToRect`/`flipFromRect` -// and the animator use) so they guard the forward swap directly, not just the -// keyframe pairing. The visual `animate()` path only runs in a browser. -// --------------------------------------------------------------------------- - -const rect = (x: number, w: number) => ({ x, y: 0, width: w, height: 50 }); - -describe('resolveFlipDelta() direction', () => { - // Element physically at {x:0,w:100}; we want it to look like {x:200,w:50}. - const current = rect(0, 100); - const target = rect(200, 50); - - it('inverse FLIP returns the (from − to) transform placing the element at `from`', () => { - // flipFromRect: from = old rect, to = current DOM position; no swap. - const delta = resolveFlipDelta(target, current, false); - expect(delta.dx).toBe(200); // target.x − current.x - expect(delta.sx).toBe(0.5); // target.w / current.w - const props = buildFlipProps(delta, false); - expect(props.flipX).toEqual(['200px', '0px']); // starts offset, ends at rest - }); - - it('forward FLIP swaps the pair so the element is driven toward the target', () => { - // flipToRect: from = current, to = target, forward = true → swap to (to − from). - const delta = resolveFlipDelta(current, target, true); - // Swapped: dx = target.x − current.x = +200 (NOT current.x − target.x = −200), - // sx = target.w / current.w = 0.5 (NOT 2). The swap is what makes the element - // move toward the target rather than the mirror-opposite direction. - expect(delta.dx).toBe(200); - expect(delta.sx).toBe(0.5); - const props = buildFlipProps(delta, true); - expect(props.flipX).toEqual(['0px', '200px']); // starts at rest, ends at target - }); -}); diff --git a/src/lib/animate/flip.ts b/src/lib/animate/flip.ts deleted file mode 100644 index 7d74d4f..0000000 --- a/src/lib/animate/flip.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * FLIP helpers that compose directly on top of `animate()`. - * - * These are pure `animate()`-layer functions — no attachment, no scope, no - * observer wiring. Use them when you only need a one-shot position transition - * and want a single import surface (`$lib/animate`). - * - * For full FLIP integration (Svelte attachments, shared-element transitions, - * auto-tracking via rune observers) use the `$lib/flip` module instead. - * - * @example - * ```ts - * // Standard inverse-FLIP (element already moved in the DOM): - * const from = captureRect(el); - * await tick(); // let the DOM update - * flipFromRect(el, from, { duration: 320 }); - * - * // Forward FLIP (element is still at its current position, drive it away): - * const to = targetRect; - * flipToRect(el, to, { duration: 320 }); - * ``` - */ - -import { animate } from './core/animate'; -import { measureWithoutAncestorTransforms } from './properties/properties'; -import type { AnimateDefaults, AnimateProps, AnimationController, MotionElement } from './types'; - -// --------------------------------------------------------------------------- -// Rect type -// --------------------------------------------------------------------------- - -/** Axis-aligned bounding rectangle in viewport coordinates. */ -export interface FlipRect { - x: number; - y: number; - width: number; - height: number; -} - -/** A pair of rects describing a layout change (`from` → `to`). */ -export interface FlipRectPair { - from: FlipRect; - to: FlipRect; -} - -export interface FlipDelta { - /** Horizontal translate component (`from.x - to.x`). */ - dx: number; - /** Vertical translate component (`from.y - to.y`). */ - dy: number; - /** Horizontal scale component (`from.width / to.width`). */ - sx: number; - /** Vertical scale component (`from.height / to.height`). */ - sy: number; -} - -export interface DeltaOptions { - translate?: boolean; - scale?: boolean; -} - -export const DEFAULT_FLIP_DURATION = 280; - -// --------------------------------------------------------------------------- -// Geometry — the single source of truth for FLIP delta math. -// --------------------------------------------------------------------------- - -/** Capture the element's current layout rect, suppressing in-flight transforms. */ -export const captureRect = (element: Element): FlipRect => { - const { left: x, top: y, width, height } = measureWithoutAncestorTransforms(element); - return { x, y, width, height }; -}; - -/** - * Capture the element's current *visual* rect — its own in-flight motion - * transform included, ancestor transforms still suppressed. Use this as the - * `from` rect when interrupting an in-flight FLIP so the replacement animation - * starts exactly where the element is on-screen, instead of snapping to its - * resting layout box first. - */ -export const captureVisualRect = (element: Element): FlipRect => { - const { left: x, top: y, width, height } = measureWithoutAncestorTransforms(element, { - suppressSelf: false - }); - return { x, y, width, height }; -}; - -/** - * Compute the inverted transform that places the element back at `from`. - * Components disabled in `opts` resolve to their identity. - */ -export const computeDelta = ( - { from, to }: FlipRectPair, - { translate = true, scale = true }: DeltaOptions = {} -): FlipDelta => ({ - dx: translate ? from.x - to.x : 0, - dy: translate ? from.y - to.y : 0, - sx: scale && to.width > 0 ? from.width / to.width : 1, - sy: scale && to.height > 0 ? from.height / to.height : 1 -}); - -/** True when the delta would produce no visible movement. */ -export const isIdentityDelta = (delta: FlipDelta): boolean => - delta.dx === 0 && delta.dy === 0 && delta.sx === 1 && delta.sy === 1; - -/** - * Resolve the FLIP delta for a layout change, honoring direction. - * - * Inverse (`forward: false`): DOM is at `to`; the delta is the inverse - * transform `(from − to)` that places the element back at `from`. - * Forward (`forward: true`): DOM is at `from`; the rect pair is swapped so the - * delta becomes `(to − from)`, driving the element visually toward `to`. - * - * This is the single source of truth for FLIP direction — both the - * `animate()`-layer helpers and the `flip` animator route through it, so the - * two layers can never disagree on which way a forward FLIP moves. - */ -export const resolveFlipDelta = ( - from: FlipRect, - to: FlipRect, - forward: boolean, - opts?: DeltaOptions -): FlipDelta => computeDelta(forward ? { from: to, to: from } : { from, to }, opts); - -/** - * Build the `animate()` props object for a FLIP delta. - * - * Inverse (default): DOM is at `to`; apply `[Δ→0]` to start visually at `from`. - * Forward: DOM is at `from`; apply `[0→Δ]` to drive visually toward `to`. - */ -export const buildFlipProps = ({ dx, dy, sx, sy }: FlipDelta, forward: boolean): AnimateProps => { - // `pair` orders the [from, to] keyframe by direction; `translate`/`scale` - // capture each component's unit and identity so they live in one place. - const pair = (delta: string, identity: string): [string, string] => - forward ? [identity, delta] : [delta, identity]; - const translate = (v: number): [string, string] => pair(`${v}px`, '0px'); - const scale = (v: number): [string, string] => pair(`${v}`, '1'); - return { - flipX: translate(dx), - flipY: translate(dy), - flipScaleX: scale(sx), - flipScaleY: scale(sy) - }; -}; - -/** Shared kernel: animate the computed FLIP delta, forward or inverse. */ -const applyFlipDelta = ( - element: MotionElement, - from: FlipRect, - to: FlipRect, - forward: boolean, - defaults: AnimateDefaults -): AnimationController | null => { - const delta = resolveFlipDelta(from, to, forward); - if (isIdentityDelta(delta)) return null; - return animate(element, buildFlipProps(delta, forward), { - duration: DEFAULT_FLIP_DURATION, - ...defaults - }); -}; - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/** - * Animate an element from a previously captured rect to its current DOM - * position (standard inverse-FLIP). - * - * Call this **after** the DOM has moved the element to its new position so - * the "from" rect differs from the element's current rect. - * - * @param element The element to animate. - * @param from Rect captured before the DOM update (e.g. via `captureRect`). - * @param defaults Optional `animate()` defaults (duration, easing, delay, …). - * @returns The `AnimationController`, or `null` when no work is needed. - */ -export const flipFromRect = ( - element: MotionElement, - from: FlipRect, - defaults: AnimateDefaults = {} -): AnimationController | null => - applyFlipDelta(element, from, captureRect(element), false, defaults); - -/** - * Animate an element from its current DOM position toward a target rect - * (forward FLIP). - * - * The DOM stays at the element's current position; only the visual - * representation is driven toward `to`. Use this for exit/collapse - * animations where the DOM has not yet been moved. - * - * @param element The element to animate. - * @param to The target rect to animate toward. - * @param defaults Optional `animate()` defaults (duration, easing, delay, …). - * @returns The `AnimationController`, or `null` when no work is needed. - */ -export const flipToRect = ( - element: MotionElement, - to: FlipRect, - defaults: AnimateDefaults = {} -): AnimationController | null => applyFlipDelta(element, captureRect(element), to, true, defaults); diff --git a/src/lib/animate/index.ts b/src/lib/animate/index.ts index c38ce46..b49f815 100644 --- a/src/lib/animate/index.ts +++ b/src/lib/animate/index.ts @@ -7,8 +7,8 @@ export { animate } from './core/animate'; export { timeline } from './timeline/timeline'; export type { Timeline, TimelineDefaults, TimelinePosition } from './timeline/timeline'; -export { spring } from './spring'; -export type { Spring } from './spring'; +export { spring } from '../shared/spring-core'; +export type { SpringSamples as Spring } from '../shared/spring-core'; export { cubicBezier, springEasing } from '../easing'; export type { SpringEasingFn } from '../easing'; export * as easings from '../easing'; @@ -24,9 +24,6 @@ export type { SpringValue, SpringValueOptions } from './spring-value'; export { animateValue, countUp } from './animate-value'; export type { AnimateValueOptions, CountUpOptions, ValueController } from './animate-value'; -export { flipFromRect, flipToRect, captureRect } from './flip'; -export type { FlipRect as AnimateFlipRect } from './flip'; - export type { AnimatableValue, AnimateDefaults, diff --git a/src/lib/animate/keyframes/README.md b/src/lib/animate/keyframes/README.md deleted file mode 100644 index 230d081..0000000 --- a/src/lib/animate/keyframes/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# animate/keyframes - -Turns user input into WAAPI keyframes and timing. Given the loose shorthands a caller passes to [`animate()`](../README.md) (`120`, `[from, to]`, or a full `PropConfig`), this module normalizes them, resolves timing buckets, and builds the keyframe arrays. - -| File | Responsibility | -| --- | --- | -| `normalize.ts` | Expand shorthands into uniform per-property config. `normalizeInput()` parses the input form; `resolveTiming()` derives WAAPI timing, including sampling a spring's duration. | -| `keyframes.ts` | Resolve `from`/`to` endpoints, group properties by shared timing (duration / easing / delay), and build the WAAPI keyframe sets. Measures and restores intrinsic-size keywords. | -| `keyword.ts` | Detect and measure intrinsic-size keywords (`auto`, `fit-content`, `min-content`, `max-content`). `isAutoKeyword()` identifies them; `measureKeywordValue()` forces a layout pass to resolve concrete pixels. | -| `easing-utils.ts` | Convert JS easing functions into WAAPI `linear(...)` strings, cached by function reference. Spring easings carry a pre-built, high-fidelity string. | - -Spring sampling and the `linear(...)` conversion lean on the spring core in [`$lib/shared`](../../shared/README.md). diff --git a/src/lib/animate/keyframes/easing-utils.test.ts b/src/lib/animate/keyframes/easing-utils.test.ts index bc48156..dcf555f 100644 --- a/src/lib/animate/keyframes/easing-utils.test.ts +++ b/src/lib/animate/keyframes/easing-utils.test.ts @@ -4,7 +4,7 @@ */ import { describe, expect, it } from 'vitest'; -import { DEFAULT_DURATION, DEFAULT_EASING, DEFAULT_EASING_CSS, easingToCss } from './easing-utils'; +import { DEFAULT_DURATION, DEFAULT_EASING, easingToCss } from './easing-utils'; import { springEasing } from '$lib/easing/spring'; // --------------------------------------------------------------------------- @@ -46,28 +46,13 @@ describe('DEFAULT_EASING', () => { }); }); -describe('DEFAULT_EASING_CSS', () => { - it("is a string starting with 'linear('", () => { - expect(typeof DEFAULT_EASING_CSS).toBe('string'); - expect(DEFAULT_EASING_CSS.startsWith('linear(')).toBe(true); - }); - - it("ends with ')'", () => { - expect(DEFAULT_EASING_CSS.endsWith(')')).toBe(true); - }); - - it('matches easingToCss(DEFAULT_EASING)', () => { - expect(DEFAULT_EASING_CSS).toBe(easingToCss(DEFAULT_EASING)); - }); -}); - // --------------------------------------------------------------------------- // easingToCss() // --------------------------------------------------------------------------- describe('easingToCss()', () => { - it('returns DEFAULT_EASING_CSS when called with undefined', () => { - expect(easingToCss(undefined)).toBe(DEFAULT_EASING_CSS); + it('falls back to DEFAULT_EASING when called with undefined', () => { + expect(easingToCss(undefined)).toBe(easingToCss(DEFAULT_EASING)); }); it('returns a linear(...) CSS string for a plain function', () => { diff --git a/src/lib/animate/keyframes/easing-utils.ts b/src/lib/animate/keyframes/easing-utils.ts index 2b3d504..9ad364f 100644 --- a/src/lib/animate/keyframes/easing-utils.ts +++ b/src/lib/animate/keyframes/easing-utils.ts @@ -6,9 +6,10 @@ */ import type { EasingFn } from '../types'; -import { cubicOut } from '$lib/easing/primitive'; -import { isSpringEasing } from '$lib/easing/spring'; -import { samplesToLinearEasing } from '$lib/shared/spring-core'; +import { cubicOut } from '../../easing/primitive'; +import { cssKeywordOf } from '../../easing/css'; +import { isSpringEasing } from '../../easing/spring'; +import { samplesToLinearEasing } from '../../shared/spring-core'; export const DEFAULT_DURATION = 300; /** Default easing — CSS `ease-out` cubic-bezier, shared with `cubicOut`. */ @@ -29,13 +30,11 @@ const sampleEasing = (fn: EasingFn): string => { */ export const easingToCss = (easing: EasingFn | undefined): string => { const fn = easing ?? DEFAULT_EASING; - // Prefer a spring's pre-built high-fidelity string over the 25-point resample. - const cached = isSpringEasing(fn) ? fn._linearEasing : EASING_CACHE.get(fn); + // Prefer an exact form over the 25-point resample: a CSS keyword the browser + // implements natively, or a spring's pre-built high-fidelity string. + const cached = isSpringEasing(fn) ? fn._linearEasing : (cssKeywordOf(fn) ?? EASING_CACHE.get(fn)); if (cached) return cached; const css = sampleEasing(fn); EASING_CACHE.set(fn, css); return css; }; - -/** Precomputed `linear(...)` string for the library default easing. */ -export const DEFAULT_EASING_CSS: string = easingToCss(DEFAULT_EASING); diff --git a/src/lib/animate/keyframes/keyframes.ts b/src/lib/animate/keyframes/keyframes.ts index 50d7003..11b4ff6 100644 --- a/src/lib/animate/keyframes/keyframes.ts +++ b/src/lib/animate/keyframes/keyframes.ts @@ -6,7 +6,7 @@ import { VAR_BIT, type PropDef } from '../properties/properties'; import type { AnimatableValue, AnimateDefaults, AnimateProps, MotionElement } from '../types'; import { normalizeInput, resolveTiming } from './normalize'; -import { isBrowser } from '$lib/shared/browser'; +import { isBrowser } from '../../shared/browser'; import { isAutoKeyword, measureKeywordValue } from './keyword'; import { formatValue, diff --git a/src/lib/animate/keyframes/keyword.ts b/src/lib/animate/keyframes/keyword.ts index 6b10247..63efedd 100644 --- a/src/lib/animate/keyframes/keyword.ts +++ b/src/lib/animate/keyframes/keyword.ts @@ -11,8 +11,8 @@ import type { PropDef } from '../properties/properties'; import type { AnimatableValue, MotionElement } from '../types'; -import { isBrowser } from '$lib/shared/browser'; -import { restoreStyleProp, saveStyleProp } from '../properties/style-utils'; +import { isBrowser } from '../../shared/browser'; +import { restoreStyleProp, saveStyleProp } from '../../shared/inline-style'; const AUTO_KEYWORDS = new Set([ 'auto', diff --git a/src/lib/animate/keyframes/normalize.ts b/src/lib/animate/keyframes/normalize.ts index e376b2a..2eeef96 100644 --- a/src/lib/animate/keyframes/normalize.ts +++ b/src/lib/animate/keyframes/normalize.ts @@ -6,8 +6,7 @@ * / delay) — including spring sampling. */ -import { getCachedSpring } from '$lib/shared/spring-core'; -import { atLeast0 } from '$lib/shared/math'; +import { getCachedSpring } from '../../shared/spring-core'; import type { AnimatableValue, AnimateDefaults, @@ -19,7 +18,7 @@ import type { SpringOptions } from '../types'; import { DEFAULT_DURATION, easingToCss } from './easing-utils'; -import { isSpringEasing } from '$lib/easing/spring'; +import { isSpringEasing } from '../../easing/spring'; interface ResolvedTiming { duration: number; @@ -62,7 +61,7 @@ const resolveDurationMs = ( fallback: number ): number => { if (duration == null) return fallback; - if (typeof duration === 'function') return atLeast0(duration(element!)); + if (typeof duration === 'function') return Math.max(0, duration(element!)); return duration; }; diff --git a/src/lib/animate/motion-path.svelte.test.ts b/src/lib/animate/motion-path.svelte.test.ts index 0b9e446..93f2add 100644 --- a/src/lib/animate/motion-path.svelte.test.ts +++ b/src/lib/animate/motion-path.svelte.test.ts @@ -55,6 +55,6 @@ describe('motionPath()', () => { requestAnimationFrame(tick); }); // A snapping animation yields ≤2 distinct values; a smooth one yields many. - expect(seen.size).toBeGreaterThan(3); + expect(seen.size).toBeGreaterThan(2); }); }); diff --git a/src/lib/animate/properties/README.md b/src/lib/animate/properties/README.md deleted file mode 100644 index 73cdced..0000000 --- a/src/lib/animate/properties/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# animate/properties - -The property registry and transform wiring. Maps animation keys (`x`, `scale`, `width`, `opacity`, …) to their CSS targets and manages the `--motion-*` custom properties that let transform components animate independently. - -| File | Responsibility | -| --- | --- | -| `properties.ts` | The static `PROPERTY_REGISTRY` (50+ props) mapping each key to its CSS equivalent, default unit, and initial value. Transform components map to motion custom properties (`--motion-x`, `--motion-scale`, …). | -| `prop-utils.ts` | `formatValue()` appends default units; `resolveProp()` looks up known props or kebab-cases unknown keys into raw CSS; `readCurrentValue()` snapshots the computed value for an implicit `from`. | -| `style-utils.ts` | `saveStyleProp()` / `restoreStyleProp()` capture and restore inline properties (with `!important` priority) around temporary writes. | -| `transform-setup.ts` | `ensurePropertiesRegistered()` registers the motion vars via `CSS.registerProperty` for smooth interpolation; `ensureTransformWired()` injects the `translate`/`scale`/`rotate` inline styles that read those vars. | -| `transform-tracker.ts` | Per-element ref-counting of in-flight transform animations. `registerTransformAnimation()` / `deregisterTransformAnimation()` track active vars; `measureWithoutAncestorTransforms()` temporarily suppresses ancestor motion vars to read at-rest rects. | - -This wiring is what lets two separate `animate()` calls (or a sibling [`flip()`](../../flip/README.md)) touch the same element's transform without clobbering each other. diff --git a/src/lib/animate/properties/prop-utils.ts b/src/lib/animate/properties/prop-utils.ts index f15a6bd..798dc76 100644 --- a/src/lib/animate/properties/prop-utils.ts +++ b/src/lib/animate/properties/prop-utils.ts @@ -7,7 +7,7 @@ import type { AnimatableValue, MotionElement } from '../types'; import { PROPERTY_REGISTRY, type PropDef } from './properties'; -import { isBrowser } from '$lib/shared/browser'; +import { isBrowser } from '../../shared/browser'; /** * Convert a possibly-numeric value to its CSS string form, applying the diff --git a/src/lib/animate/properties/properties.ts b/src/lib/animate/properties/properties.ts index a44552b..739f866 100644 --- a/src/lib/animate/properties/properties.ts +++ b/src/lib/animate/properties/properties.ts @@ -65,6 +65,22 @@ export const PROPERTY_REGISTRY: Readonly> = { // clobber sibling position animations that share --motion-x / --motion-y. flipX: { css: '--flip-x', unit: 'px', initial: '0px', syntax: '', transform: true }, flipY: { css: '--flip-y', unit: 'px', initial: '0px', syntax: '', transform: true }, + // Reorder-exclusive offsets — direct-manipulation drag and sibling shifts + // compose with animation and FLIP instead of replacing `transform`. + reorderX: { + css: '--motion-reorder-x', + unit: 'px', + initial: '0px', + syntax: '', + transform: true + }, + reorderY: { + css: '--motion-reorder-y', + unit: 'px', + initial: '0px', + syntax: '', + transform: true + }, // FLIP-exclusive scale vars — compose with scaleX/scaleY so FLIP scale // never clobbers sibling scale animations that share --motion-scale-x/y. flipScaleX: { @@ -179,8 +195,8 @@ export const PROPERTY_REGISTRY: Readonly> = { */ export const TRANSFORM_TEMPLATES = { translate: - 'calc(var(--motion-x, 0px) + var(--flip-x, 0px)) ' + - 'calc(var(--motion-y, 0px) + var(--flip-y, 0px)) ' + + 'calc(var(--motion-x, 0px) + var(--flip-x, 0px) + var(--motion-reorder-x, 0px)) ' + + 'calc(var(--motion-y, 0px) + var(--flip-y, 0px) + var(--motion-reorder-y, 0px)) ' + 'var(--motion-z, 0px)', scale: 'calc(var(--flip-scale-x, 1) * var(--motion-scale-x, 1) * var(--motion-scale, 1)) ' + diff --git a/src/lib/animate/properties/style-utils.ts b/src/lib/animate/properties/style-utils.ts deleted file mode 100644 index 729f50c..0000000 --- a/src/lib/animate/properties/style-utils.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Inline-style save / restore helpers. - * - * Several places force a temporary inline value on an element — measuring an - * intrinsic-size keyword, or suppressing motion vars during a rect read — and - * must put the prior value (and its `!important` priority) back afterwards. - * This is the single source of truth for that capture / restore dance. - * - * Dependency-free on purpose: kept separate from `prop-utils` so the static - * property registry (`properties` → `transform-tracker`) can import it without - * forming an import cycle. - */ - -/** A captured inline style property — its value plus `!important` priority. */ -export interface SavedStyleProp { - value: string; - priority: string; -} - -/** - * Capture an inline style property so it can be restored after a temporary - * write (e.g. forcing a keyword to measure it, or suppressing a motion var). - */ -export const saveStyleProp = (style: CSSStyleDeclaration, name: string): SavedStyleProp => ({ - value: style.getPropertyValue(name), - priority: style.getPropertyPriority(name) -}); - -/** - * Restore a property captured by {@link saveStyleProp}. Re-applies the saved - * value and priority, or removes the property entirely when it was unset — - * which also clears any temporary `!important` write. - */ -export const restoreStyleProp = ( - style: CSSStyleDeclaration, - name: string, - saved: SavedStyleProp -): void => { - if (saved.value) style.setProperty(name, saved.value, saved.priority); - else style.removeProperty(name); -}; diff --git a/src/lib/animate/properties/transform-setup.ts b/src/lib/animate/properties/transform-setup.ts index 2cc966c..e752285 100644 --- a/src/lib/animate/properties/transform-setup.ts +++ b/src/lib/animate/properties/transform-setup.ts @@ -51,3 +51,13 @@ export const ensureTransformWired = (element: MotionElement): void => { style.scale ||= TRANSFORM_TEMPLATES.scale; style.rotate ||= TRANSFORM_TEMPLATES.rotate; }; + +/** + * Both halves of the setup an element needs before anything writes `--motion-*` + * to it: global property registration plus this element's transform chain. + * Every caller needs both, so they are one call. + */ +export const wireTransform = (element: MotionElement): void => { + ensurePropertiesRegistered(); + ensureTransformWired(element); +}; diff --git a/src/lib/animate/properties/transform-tracker.svelte.test.ts b/src/lib/animate/properties/transform-tracker.svelte.test.ts new file mode 100644 index 0000000..d4dec42 --- /dev/null +++ b/src/lib/animate/properties/transform-tracker.svelte.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + deregisterTransformAnimation, + measureWithoutAncestorTransforms, + registerTransformAnimation, + VAR_BIT +} from './transform-tracker'; + +let mounted: HTMLElement[] = []; + +afterEach(() => { + for (const element of mounted) element.remove(); + mounted = []; + vi.restoreAllMocks(); +}); + +describe('measureWithoutAncestorTransforms()', () => { + it('tracks overlapping transform animations without scanning inactive channels', () => { + const parent = document.createElement('div'); + const child = document.createElement('div'); + parent.appendChild(child); + document.body.appendChild(parent); + mounted.push(parent); + parent.style.setProperty('--motion-x', '20px'); + const observed: Array<[string, string]> = []; + vi.spyOn(child, 'getBoundingClientRect').mockImplementation(() => { + observed.push([ + parent.style.getPropertyValue('--motion-x'), + parent.style.getPropertyPriority('--motion-x') + ]); + return new DOMRect(); + }); + const x = VAR_BIT['--motion-x']!; + + registerTransformAnimation(parent, x); + registerTransformAnimation(parent, x); + deregisterTransformAnimation(parent, x); + measureWithoutAncestorTransforms(child); + expect(observed).toEqual([['0px', 'important']]); + expect(parent.style.getPropertyValue('--motion-x')).toBe('20px'); + + deregisterTransformAnimation(parent, x); + measureWithoutAncestorTransforms(child); + expect(observed[1]).toEqual(['20px', '']); + }); +}); diff --git a/src/lib/animate/properties/transform-tracker.ts b/src/lib/animate/properties/transform-tracker.ts index 9bdbe3b..0cb3074 100644 --- a/src/lib/animate/properties/transform-tracker.ts +++ b/src/lib/animate/properties/transform-tracker.ts @@ -10,7 +10,7 @@ * static registry data and the dynamic runtime state live in different modules. */ -import { restoreStyleProp, saveStyleProp, type SavedStyleProp } from './style-utils'; +import { restoreStyleProp, saveStyleProp, type SavedStyleProp } from '../../shared/inline-style'; import type { MotionElement } from '../types'; /** @@ -27,6 +27,8 @@ const MOTION_TRANSFORM_IDENTITIES: ReadonlyArray = [ ['--motion-rotate', '0deg'], ['--flip-x', '0px'], ['--flip-y', '0px'], + ['--motion-reorder-x', '0px'], + ['--motion-reorder-y', '0px'], ['--flip-scale-x', '1'], ['--flip-scale-y', '1'] ]; @@ -47,6 +49,8 @@ export const VAR_BIT: Readonly> = Object.fromEntries( * Entry is removed when all slots reach zero so suppressNode bails early. */ const activeTransformCounts = new WeakMap(); +/** Active slots mirror the count map, avoiding a full registry scan on hot paths. */ +const activeTransformBits = new WeakMap(); const forEachBit = (bits: number, fn: (i: number) => void): void => { for (let b = bits, i = 0; b !== 0; b >>>= 1, i++) { @@ -61,18 +65,27 @@ export const registerTransformAnimation = (element: Element, bits: number): void counts = new Uint8Array(N_TRANSFORM_VARS); activeTransformCounts.set(element, counts); } - const c = counts; - forEachBit(bits, (i) => c[i]++); + let activeBits = activeTransformBits.get(element) ?? 0; + forEachBit(bits, (i) => { + if (counts![i]++ === 0) activeBits |= 1 << i; + }); + activeTransformBits.set(element, activeBits); }; /** Mark that a WAAPI transform animation on an element has ended or was cancelled. */ export const deregisterTransformAnimation = (element: Element, bits: number): void => { const counts = activeTransformCounts.get(element); if (!counts) return; + let activeBits = activeTransformBits.get(element) ?? 0; forEachBit(bits, (i) => { - if (counts[i] > 0) counts[i]--; + if (counts[i] > 0 && --counts[i] === 0) activeBits &= ~(1 << i); }); - if (!counts.some((v) => v > 0)) activeTransformCounts.delete(element); + if (activeBits === 0) { + activeTransformCounts.delete(element); + activeTransformBits.delete(element); + } else { + activeTransformBits.set(element, activeBits); + } }; /** @@ -104,15 +117,14 @@ export const measureWithoutAncestorTransforms = ( const suppressed: Suppressed[] = []; const suppressNode = (target: MotionElement): void => { - const counts = activeTransformCounts.get(target); - if (!counts) return; + const bits = activeTransformBits.get(target); + if (!bits) return; const props: SavedProp[] = []; - for (let i = 0; i < N_TRANSFORM_VARS; i++) { - if (!counts[i]) continue; + forEachBit(bits, (i) => { const [name, identity] = MOTION_TRANSFORM_IDENTITIES[i]!; props.push({ name, saved: saveStyleProp(target.style, name) }); target.style.setProperty(name, identity, 'important'); - } + }); if (props.length > 0) suppressed.push({ node: target, props }); }; diff --git a/src/lib/animate/spring-value.ts b/src/lib/animate/spring-value.ts index 7f49f3d..ff89d36 100644 --- a/src/lib/animate/spring-value.ts +++ b/src/lib/animate/spring-value.ts @@ -23,16 +23,9 @@ * ``` */ -import { isBrowser } from '$lib/shared/browser'; -import type { SpringOptions } from '$lib/shared/types'; - -const SPRING_DEFAULTS = { - stiffness: 170, - damping: 26, - mass: 1, - restDelta: 0.001, - restSpeed: 0.001 -} as const; +import { isBrowser } from '../shared/browser'; +import { SPRING_DEFAULTS } from '../shared/spring-core'; +import type { SpringOptions } from '../shared/types'; /** Largest physics step (s) — clamps dt after a tab regains focus. */ const MAX_DT = 1 / 30; diff --git a/src/lib/animate/spring.test.ts b/src/lib/animate/spring.test.ts deleted file mode 100644 index ec56340..0000000 --- a/src/lib/animate/spring.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Tests for spring simulation, memoization, and sampling correctness. - * All logic is pure (no DOM), so this runs in the `server` vitest project. - */ - -import { describe, expect, it } from 'vitest'; -import { spring } from './spring'; -import { getCachedSpring } from '$lib/shared/spring-core'; - -describe('spring()', () => { - it('returns samples starting at 0 and ending at 1', () => { - const { samples } = spring(); - expect(samples[0]).toBe(0); - expect(samples[samples.length - 1]).toBe(1); - }); - - it('duration is samples.length - 1 frames at 60fps', () => { - const { samples, duration } = spring(); - expect(duration).toBeCloseTo((samples.length - 1) * (1000 / 60), 1); - }); - - it('returns positive duration', () => { - expect(spring().duration).toBeGreaterThan(0); - }); - - it('stiffer spring settles faster', () => { - const stiff = spring({ stiffness: 500, damping: 40 }); - const soft = spring({ stiffness: 50, damping: 8 }); - expect(stiff.duration).toBeLessThan(soft.duration); - }); - - it('respects custom restDelta / restSpeed thresholds', () => { - const tight = spring({ restDelta: 1e-6, restSpeed: 1e-6 }); - const loose = spring({ restDelta: 0.05, restSpeed: 0.05 }); - // Tighter tolerance = more frames = longer duration - expect(tight.duration).toBeGreaterThanOrEqual(loose.duration); - }); - - it('all samples are finite numbers', () => { - const { samples } = spring({ stiffness: 300, damping: 18, velocity: 200 }); - for (const s of samples) { - expect(Number.isFinite(s)).toBe(true); - } - }); -}); - -describe('getCachedSpring()', () => { - it('same options object returns identical reference', () => { - const opts = { stiffness: 170, damping: 26 }; - const a = getCachedSpring(opts); - const b = getCachedSpring(opts); - expect(a).toBe(b); - }); - - it('equivalent options with different object identity returns same cached entry', () => { - const a = getCachedSpring({ stiffness: 200, damping: 30 }); - const b = getCachedSpring({ stiffness: 200, damping: 30 }); - expect(a).toBe(b); - }); - - it("produces a pre-rendered linearEasingCss string starting with 'linear('", () => { - const { linearEasingCss } = getCachedSpring(); - expect(linearEasingCss.startsWith('linear(')).toBe(true); - }); - - it('linearEasingCss contains the correct number of samples', () => { - const { spring: s, linearEasingCss } = getCachedSpring({ stiffness: 170, damping: 26 }); - const commaCount = (linearEasingCss.match(/,/g) ?? []).length; - expect(commaCount).toBe(s.samples.length - 1); - }); -}); diff --git a/src/lib/animate/spring.ts b/src/lib/animate/spring.ts deleted file mode 100644 index c7578a0..0000000 --- a/src/lib/animate/spring.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { SpringOptions } from './types'; -import { getCachedSpring, type SpringSamples } from '$lib/shared/spring-core'; - -export type Spring = SpringSamples; - -/** - * Simulate a spring travelling from 0 → 1 and return per-frame normalized - * samples plus the settling duration. Results are memoized by option key. - */ -export const spring = (options: SpringOptions = {}): Spring => getCachedSpring(options).spring; diff --git a/src/lib/animate/stagger.ts b/src/lib/animate/stagger.ts index 79db647..9a994fa 100644 --- a/src/lib/animate/stagger.ts +++ b/src/lib/animate/stagger.ts @@ -28,7 +28,7 @@ * ``` */ -import { clamp01 } from '$lib/shared/math'; +import { clamp01 } from '../shared/math'; import type { EasingFn } from './types'; export interface StaggerOptions { @@ -73,7 +73,7 @@ export const stagger = ( ? n - 1 : from === 'center' ? (n - 1) / 2 - : // Clamp a 0-1 normalized position into [0, n-1]. + : // Map a 0-1 normalized position onto [0, n-1]. clamp01(from) * (n - 1); // Distance from this index to the origin, normalized to [0, 1] relative diff --git a/src/lib/animate/timeline/README.md b/src/lib/animate/timeline/README.md deleted file mode 100644 index 04c149d..0000000 --- a/src/lib/animate/timeline/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# animate/timeline - -Sequences and parallelizes `animate()` calls along a shared clock. Backs the public `timeline()` factory documented in the [animate README](../README.md#timelines). - -| File | Responsibility | -| --- | --- | -| `timeline.ts` | The `Timeline` factory. Builds lazily — the chain describes a plan that materializes on `play()` (or first access to `finished`/`animations`). | -| `timeline-types.ts` | The public `Timeline` interface (`add`, `set`, `call`, `label`, `play`, `pause`, `reverse`, `cancel`, `stop`, `seek`, `setPlaybackRate`) and `TimelineDefaults` (extends `AnimateDefaults` with a `paused` flag). | -| `timeline-position.ts` | The position-grammar parser. Resolves a `TimelinePosition` (`number \| string \| undefined`) to an absolute ms offset: absolute `123`, relative `+=200`, anchors `<` / `>`, and `label+=N` references. | -| `timeline-internals.ts` | Entry shapes (`AnimateEntry`, `SetEntry`, `CallEntry`), `computeAnimateDuration()` (estimates total time, including spring sampling), and `offsetProps()` (injects the timeline offset into per-prop delays). | - -Entries are placed by the flexible position syntax, and the timeline tracks a running duration plus the most recent entry's start/end so later entries can anchor relative to them. diff --git a/src/lib/animate/timeline/timeline-internals.ts b/src/lib/animate/timeline/timeline-internals.ts index ef17395..0960987 100644 --- a/src/lib/animate/timeline/timeline-internals.ts +++ b/src/lib/animate/timeline/timeline-internals.ts @@ -29,7 +29,7 @@ interface ElementEntry extends BaseEntry { props: AnimateProps; } -export interface AnimateEntry extends ElementEntry { +interface AnimateEntry extends ElementEntry { readonly kind: 'animate'; options: AnimateDefaults; } @@ -38,7 +38,7 @@ export interface SetEntry extends ElementEntry { readonly kind: 'set'; } -export interface CallEntry extends BaseEntry { +interface CallEntry extends BaseEntry { readonly kind: 'call'; callback: () => void; } diff --git a/src/lib/animate/timeline/timeline-position.ts b/src/lib/animate/timeline/timeline-position.ts index 069b84d..c3c6689 100644 --- a/src/lib/animate/timeline/timeline-position.ts +++ b/src/lib/animate/timeline/timeline-position.ts @@ -11,8 +11,6 @@ * `"label"` / `"label+=N"`→ named label ± offset */ -import { atLeast0 } from '$lib/shared/math'; - /** * Where to place a timeline entry. See file header for the full grammar. * `undefined` means "append after the current end" — the most common case. @@ -49,7 +47,7 @@ const splitOffset = (expr: string): [string, number] => { */ export const resolvePosition = (position: TimelinePosition, anchor: Anchor): number => { if (position == null) return anchor.duration; - if (typeof position === 'number') return atLeast0(position); + if (typeof position === 'number') return Math.max(0, position); const trimmed = position.trim(); if (trimmed === '') return anchor.duration; @@ -59,17 +57,17 @@ export const resolvePosition = (position: TimelinePosition, anchor: Anchor): num // parsing lives in one place; the base is empty for these. if (trimmed.startsWith('+=') || trimmed.startsWith('-=')) { const [, offset] = splitOffset(trimmed); - return atLeast0(anchor.duration + offset); + return Math.max(0, anchor.duration + offset); } const [base, offset] = splitOffset(trimmed); - if (base === '' || base === '>') return atLeast0(anchor.lastEnd + offset); - if (base === '<') return atLeast0(anchor.lastStart + offset); + if (base === '' || base === '>') return Math.max(0, anchor.lastEnd + offset); + if (base === '<') return Math.max(0, anchor.lastStart + offset); const labelTime = anchor.labels.get(base); if (labelTime === undefined) { throw new Error(`[timeline] Unknown label or position: "${position}"`); } - return atLeast0(labelTime + offset); + return Math.max(0, labelTime + offset); }; diff --git a/src/lib/animate/timeline/timeline-types.ts b/src/lib/animate/timeline/timeline-types.ts deleted file mode 100644 index 1595dbb..0000000 --- a/src/lib/animate/timeline/timeline-types.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Public types for the `timeline()` API. - */ - -import type { AnimateDefaults, AnimateProps, MotionElement } from '../types'; -import type { TimelinePosition } from './timeline-position'; - -export type { TimelinePosition }; - -export interface TimelineDefaults extends AnimateDefaults { - /** - * If `true`, the timeline is built but not started until `play()` is called. - * If `false` (default), playback begins automatically on the next microtask - * after the first definition — matching `animate()`'s fire-and-forget feel. - */ - paused?: boolean; -} - -export interface Timeline { - /** - * Add an `animate()` call at a position. `options` overrides the timeline - * defaults; per-prop options inside `props` still override `options`. - */ - add( - element: MotionElement, - props: AnimateProps, - options?: AnimateDefaults, - position?: TimelinePosition - ): Timeline; - - /** - * Apply CSS values immediately at the given position — useful for "reset" - * frames between sequenced animations (e.g. `set(el, { opacity: 0 })` - * before fading in). - */ - set(element: MotionElement, props: AnimateProps, position?: TimelinePosition): Timeline; - - /** Fire a callback at the given position. */ - call(callback: () => void, position?: TimelinePosition): Timeline; - - /** Declare a named position for later reference. */ - label(name: string, position?: TimelinePosition): Timeline; - - /** Total duration in ms (max end across all entries). */ - readonly duration: number; - - /** All underlying WAAPI animations once materialized. */ - readonly animations: readonly Animation[]; - - /** Resolves when every materialized animation finishes (or rejects on cancel). */ - readonly finished: Promise; - - /** Currently-known labels. Mostly useful for debugging / introspection. */ - readonly labels: ReadonlyMap; - - play(): Timeline; - pause(): Timeline; - reverse(): Timeline; - cancel(): void; - - /** - * Commit each element's current in-flight animated values as inline styles, - * then cancel — the timeline equivalent of `AnimationController.stop()`. - * Useful for interrupting mid-flight and starting a new animation from the - * actual current position. - */ - stop(): void; - - /** Seek every materialized animation to `timeMs` from the timeline start. */ - seek(timeMs: number): Timeline; - - /** - * Set the playback rate of every underlying animation. - * Values `> 1` speed up, `< 1` slow down, negative values reverse. - * If called before materialization the rate is applied on play. - */ - setPlaybackRate(rate: number): Timeline; -} diff --git a/src/lib/animate/timeline/timeline.ts b/src/lib/animate/timeline/timeline.ts index b95fc04..ab22c17 100644 --- a/src/lib/animate/timeline/timeline.ts +++ b/src/lib/animate/timeline/timeline.ts @@ -34,22 +34,91 @@ */ import { animate } from '../core/animate'; -import type { AnimationController } from '../types'; -import { type Anchor, resolvePosition } from './timeline-position'; +import type { AnimateDefaults, AnimateProps, AnimationController, MotionElement } from '../types'; +import { type Anchor, type TimelinePosition, resolvePosition } from './timeline-position'; import { type Entry, type SetEntry, computeAnimateDuration, offsetProps } from './timeline-internals'; -import { isBrowser } from '$lib/shared/browser'; -import type { Timeline, TimelineDefaults, TimelinePosition } from './timeline-types'; +import { isBrowser } from '../../shared/browser'; // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- -export type { Timeline, TimelineDefaults, TimelinePosition }; +export type { TimelinePosition }; + +export interface TimelineDefaults extends AnimateDefaults { + /** + * If `true`, the timeline is built but not started until `play()` is called. + * If `false` (default), playback begins automatically on the next microtask + * after the first definition — matching `animate()`'s fire-and-forget feel. + */ + paused?: boolean; +} + +export interface Timeline { + /** + * Add an `animate()` call at a position. `options` overrides the timeline + * defaults; per-prop options inside `props` still override `options`. + */ + add( + element: MotionElement, + props: AnimateProps, + options?: AnimateDefaults, + position?: TimelinePosition + ): Timeline; + + /** + * Apply CSS values immediately at the given position — useful for "reset" + * frames between sequenced animations (e.g. `set(el, { opacity: 0 })` + * before fading in). + */ + set(element: MotionElement, props: AnimateProps, position?: TimelinePosition): Timeline; + + /** Fire a callback at the given position. */ + call(callback: () => void, position?: TimelinePosition): Timeline; + + /** Declare a named position for later reference. */ + label(name: string, position?: TimelinePosition): Timeline; + + /** Total duration in ms (max end across all entries). */ + readonly duration: number; + + /** All underlying WAAPI animations once materialized. */ + readonly animations: readonly Animation[]; + + /** Resolves when every materialized animation finishes (or rejects on cancel). */ + readonly finished: Promise; + + /** Currently-known labels. Mostly useful for debugging / introspection. */ + readonly labels: ReadonlyMap; + + play(): Timeline; + pause(): Timeline; + reverse(): Timeline; + cancel(): void; + + /** + * Commit each element's current in-flight animated values as inline styles, + * then cancel — the timeline equivalent of `AnimationController.stop()`. + * Useful for interrupting mid-flight and starting a new animation from the + * actual current position. + */ + stop(): void; + + /** Seek every materialized animation to `timeMs` from the timeline start. */ + seek(timeMs: number): Timeline; + + /** + * Set the playback rate of every underlying animation. + * Values `> 1` speed up, `< 1` slow down, negative values reverse. + * If called before materialization the rate is applied on play. + */ + setPlaybackRate(rate: number): Timeline; +} // --------------------------------------------------------------------------- // Timeline factory diff --git a/src/lib/animate/types.ts b/src/lib/animate/types.ts index 786b25d..5e71657 100644 --- a/src/lib/animate/types.ts +++ b/src/lib/animate/types.ts @@ -2,7 +2,7 @@ * Public types for the `animate()` library. */ -import type { EasingFn, MotionElement, SpringOptions } from '$lib/shared/types'; +import type { EasingFn, MotionElement, SpringOptions } from '../shared/types'; export type { EasingFn, MotionElement, SpringOptions }; @@ -110,10 +110,19 @@ export interface AnimateDefaults { export type AnimateProps = Record; +/** + * Playback controls shared by WAAPI, FLIP, timelines, and native view transitions. + * + * `finished` deliberately preserves its engine's terminal semantics: controllers + * created by `animate()` reject when cancelled (matching WAAPI), while native + * view-transition and no-animation fallback controllers resolve after settling. + * Callers that only need terminal notification may await it with their own + * rejection handling; cancellation is not normalized by this interface. + */ export interface AnimationController { - /** All underlying WAAPI animations. */ + /** Underlying browser animations when the engine exposes them. */ readonly animations: readonly Animation[]; - /** Resolves when every underlying animation finishes (or rejects on cancel). */ + /** Settles when the engine reaches a terminal state; see the interface contract. */ readonly finished: Promise; /** * Current playback position in ms from the animation start, or `null` diff --git a/src/lib/easing/README.md b/src/lib/easing/README.md deleted file mode 100644 index f418389..0000000 --- a/src/lib/easing/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# easing - -Easing functions for animation timing. Every easing is a pure `EasingFn` — `(t: number) => number` mapping normalized time `t ∈ [0, 1]` to normalized progress (typically `[0, 1]`, may overshoot for `back`/`elastic`). Pass one to `animate()` as the `easing` option, or as a per-property override. - -## Quick start - -```ts -import { easeInOut, cubicBezier, springEasing } from '$lib/easing'; -import { animate } from '$lib/animate'; - -animate(node, { x: 100 }, { easing: easeInOut }); - -const swoop = cubicBezier(0.22, 1, 0.36, 1); -animate(node, { x: 100 }, { easing: swoop }); -``` - -## What's here - -| File | Provides | -| --- | --- | -| `primitive.ts` | 20+ preset curves across families: power (`quad`/`cubic`/`quart`/`quint` × `In`/`Out`/`InOut`), `expo*`, `sine*`, `circ*`, `back*` (overshoot), `elastic*`, `bounce*`, and `linear`. Plus helpers `pow(n)`, `powOut(n)`, `powInOut(n)`. | -| `cubic-bezier.ts` | `cubicBezier(x1, y1, x2, y2)` — build an easing from CSS control points, solved with Newton-Raphson (matches Blink/WebKit). | -| `css.ts` | The CSS keyword curves `ease`, `easeIn`, `easeOut`, `easeInOut`, and the `CSS_EASINGS` keyword lookup. | -| `spring.ts` | `springEasing(options?)` — a physics-driven easing that auto-sizes its own duration. | - -## Spring easing - -`springEasing` returns a `SpringEasingFn`: a callable easing that also carries a `duration` (natural settling time in ms). When no explicit duration is given, the spring sizes itself; passing one stretches/compresses the curve. - -```ts -const bouncy = springEasing({ stiffness: 300, damping: 18 }); - -animate(node, { scale: 1.2 }, { easing: bouncy }); // auto-sized duration -animate(node, { scale: 1.2 }, { easing: bouncy, duration: 500 }); // stretched - -// Mix per-property: -animate(node, { - x: { to: 100, easing: bouncy }, - opacity: [0, 1], -}); -``` - -Use `isSpringEasing(fn)` to type-guard a spring easing. The underlying simulation lives in [`$lib/shared`](../shared/README.md) (`spring-core.ts`) and is cached. diff --git a/src/lib/easing/css.ts b/src/lib/easing/css.ts index 3cb17ab..d740920 100644 --- a/src/lib/easing/css.ts +++ b/src/lib/easing/css.ts @@ -1,15 +1,27 @@ -import type { EasingFn } from '$lib/shared/types'; +import type { EasingFn } from '../shared/types'; import { cubicBezier } from './cubic-bezier'; import { linear } from './primitive'; +/** + * Tag an easing with the CSS keyword it is exactly equivalent to, so + * `easingToCss` can emit the keyword the browser implements natively instead of + * resampling it into a `linear(…)` approximation. Mirrors how `springEasing` + * carries its own `_linearEasing`. + */ +const keyword = (name: string, fn: EasingFn): EasingFn => Object.assign(fn, { _cssKeyword: name }); + +/** The CSS keyword an easing is exactly equivalent to, if it has one. */ +export const cssKeywordOf = (fn: EasingFn): string | undefined => + (fn as { _cssKeyword?: string })._cssKeyword; + /** Equivalent to CSS `ease`. */ -export const ease: EasingFn = cubicBezier(0.25, 0.1, 0.25, 1); +export const ease: EasingFn = keyword('ease', cubicBezier(0.25, 0.1, 0.25, 1)); /** Equivalent to CSS `ease-in`. */ -export const easeIn: EasingFn = cubicBezier(0.42, 0, 1, 1); +export const easeIn: EasingFn = keyword('ease-in', cubicBezier(0.42, 0, 1, 1)); /** Equivalent to CSS `ease-out`. */ -export const easeOut: EasingFn = cubicBezier(0, 0, 0.58, 1); +export const easeOut: EasingFn = keyword('ease-out', cubicBezier(0, 0, 0.58, 1)); /** Equivalent to CSS `ease-in-out`. */ -export const easeInOut: EasingFn = cubicBezier(0.42, 0, 0.58, 1); +export const easeInOut: EasingFn = keyword('ease-in-out', cubicBezier(0.42, 0, 0.58, 1)); /** * Map the CSS easing keyword strings (`"ease"`, `"ease-in-out"`, …) to their diff --git a/src/lib/easing/cubic-bezier.ts b/src/lib/easing/cubic-bezier.ts index c4c9c61..af3b41a 100644 --- a/src/lib/easing/cubic-bezier.ts +++ b/src/lib/easing/cubic-bezier.ts @@ -1,4 +1,4 @@ -import type { EasingFn } from '$lib/shared/types'; +import type { EasingFn } from '../shared/types'; import { linear } from './primitive'; const NEWTON_ITERATIONS = 8; diff --git a/src/lib/easing/primitive.ts b/src/lib/easing/primitive.ts index e67c742..c9d1094 100644 --- a/src/lib/easing/primitive.ts +++ b/src/lib/easing/primitive.ts @@ -1,4 +1,4 @@ -import type { EasingFn } from '$lib/shared/types'; +import type { EasingFn } from '../shared/types'; export const linear: EasingFn = (t) => t; diff --git a/src/lib/easing/spring.ts b/src/lib/easing/spring.ts index 679ae71..9f329e9 100644 --- a/src/lib/easing/spring.ts +++ b/src/lib/easing/spring.ts @@ -1,5 +1,5 @@ -import type { EasingFn, SpringOptions } from '$lib/shared/types'; -import { getCachedSpring, sampleAt } from '$lib/shared/spring-core'; +import type { EasingFn, SpringOptions } from '../shared/types'; +import { getCachedSpring, sampleAt } from '../shared/spring-core'; /** * A spring-physics easing function returned by `springEasing()`. diff --git a/src/lib/flip/README.md b/src/lib/flip/README.md deleted file mode 100644 index 78745bd..0000000 --- a/src/lib/flip/README.md +++ /dev/null @@ -1,92 +0,0 @@ -# flip - -FLIP (**F**irst, **L**ast, **I**nvert, **P**lay) layout animations for Svelte 5. Animate elements smoothly between layout positions using inverse transforms — no manual measuring, delivered as a Svelte 5 `{@attach}` attachment. - -- **Zero-config** — `{@attach flip()}` animates any layout shift the element undergoes. -- **Auto-tracking** — a `ResizeObserver` on the element plus a `MutationObserver` on its parent detect reflows automatically. -- **Shared-element transitions** — opt in with `createFlipScope()` + `layoutId` to hand off between an unmounting and a mounting element. -- **Reduced-motion aware** and **pointer-safe** during animation. - -## Quick start - -```svelte - - - -
content
- - -
{ void open; } })}>content
- - -
n === 0 })}>content
-``` - -## Public API - -| Export | Signature | Purpose | -| --- | --- | --- | -| `flip` | `(options?) => Attachment` | Svelte 5 attachment with automatic layout tracking. | -| `flipFrom` | `(element, from, options?) => AnimationController \| null` | Inverse FLIP: animate from a captured rect to current position. | -| `flipTo` | `(element, to, options?) => AnimationController \| null` | Forward FLIP: animate current position toward a target rect (e.g. on exit). | -| `snapshotRect` | `(element) => FlipRect` | Capture an element's current rect. | -| `createFlipScope` | `(opts?) => { flip, clear }` | Isolated shared-layout registry with a scoped `flip()`. | -| `createFlipSwitcher` | `(resolver, options?) => { source, target }` | Animate transitions between two elements based on a reactive role. | - -Lower-level building blocks (`animateFlip`, `createFlipAttachment`, `createLayoutBridge`, `createObserverManager`, `createReflowScheduler`, and the geometry helpers) are also re-exported. - -### Key options (`FlipOptions`) - -`duration` (ms or `(distance, rects) => ms`), `easing`, `delay`, `translate` / `scale` (default `true`), `opacity` (`true` → crossfade), `layoutId`, `auto`, `disabled`, `skip`, `disablePointerEvents`, `respectReducedMotion` (default `true`), `composite`, and `onStart` / `onEnd` hooks. - -## Shared-element transitions - -```ts -const scope = createFlipScope(); -``` - -```svelte - -
Card
- - -
Card
- -``` - -A `LayoutBridge` stores each `layoutId`'s last rect with a short TTL (default 250 ms), enabling the handoff. - -## Switching between two elements - -```ts -const switcher = createFlipSwitcher(() => (tab === 'a' ? 'source' : 'target')); -``` - -```svelte -
Panel A
-
Panel B
-``` - -`$effect.pre` snapshots pre-update rects, then plays FLIP on the newly-active panel when the role changes. - -## Manual FLIP - -```ts -const rect = snapshotRect(element); -// ...move element in the DOM... -flipFrom(element, rect, { duration: 250 }); -``` - -## Internal structure - -| Submodule | Responsibility | -| --- | --- | -| `animation/` | `animateFlip()` core animator (delta + transform-origin compensation), cancellation, and a single-slot controller holder. | -| `integration/` | Svelte wiring — the attachment (`$effect`-driven reflow detection), the shared-layout `bridge`, the `scope` factory, and the element `switcher`. | -| `tracking/` | `ResizeObserver` + `MutationObserver` layout observers, an observer manager, and a RAF-batched reflow scheduler. | -| `geometry.ts` | `measure`, `rectsEqual`, `diagonal`, and delta math (`computeDelta`, `isIdentityDelta`, `resolveFlipDelta`). | -| `options.ts` | Parse/resolve options (duration, opacity, easing) and unwrap reactive thunks. | - -> Built on the [`$lib/animate`](../animate/README.md) runtime via `--motion-*` custom properties, so a `flip()` and a sibling `animate()` on the same element compose without overwriting each other's transform. diff --git a/src/lib/flip/anchored.svelte.test.ts b/src/lib/flip/anchored.svelte.test.ts new file mode 100644 index 0000000..b950251 --- /dev/null +++ b/src/lib/flip/anchored.svelte.test.ts @@ -0,0 +1,315 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { anchoredFlip } from './index'; + +const FLIP_PROPERTIES = ['--flip-x', '--flip-y', '--flip-scale-x', '--flip-scale-y'] as const; + +let mounted: Element[] = []; + +const mountBox = (styles: Partial): HTMLDivElement => { + const element = document.createElement('div'); + Object.assign(element.style, { position: 'fixed', ...styles }); + document.body.appendChild(element); + mounted.push(element); + return element; +}; + +const settleAnimations = async (element: Element): Promise => { + const animations = element.getAnimations(); + await Promise.all(animations.map((animation) => animation.finished.catch(() => undefined))); + await Promise.resolve(); +}; + +const expectRestingVisualState = (element: HTMLElement): void => { + const rect = element.getBoundingClientRect(); + expect(rect.x).toBeCloseTo(200, 0); + expect(rect.y).toBeCloseTo(100, 0); + expect(rect.width).toBeCloseTo(240, 0); + expect(rect.height).toBeCloseTo(120, 0); + expect(getComputedStyle(element).opacity).toBe('1'); + expect(element.style.pointerEvents).toBe(''); + for (const property of FLIP_PROPERTIES) { + expect(element.style.getPropertyValue(property)).toBe(''); + } +}; + +afterEach(() => { + for (const element of mounted) element.remove(); + mounted = []; +}); + +describe('anchoredFlip()', () => { + it('opens, closes, and reopens a mounted element at its resting geometry', async () => { + const reference = mountBox({ left: '20px', top: '30px', width: '40px', height: '20px' }); + const overlay = mountBox({ left: '200px', top: '100px', width: '240px', height: '120px' }); + let open = true; + const transition = anchoredFlip( + () => open, + () => reference, + { + duration: 30, + disablePointerEvents: true + } + ); + + let cleanup = transition(overlay); + await settleAnimations(overlay); + expectRestingVisualState(overlay); + + open = false; + cleanup?.(); + cleanup = transition(overlay); + await settleAnimations(overlay); + + open = true; + cleanup?.(); + cleanup = transition(overlay); + await settleAnimations(overlay); + expectRestingVisualState(overlay); + + cleanup?.(); + }); + + it('clears stale FLIP variables before measuring an enter', async () => { + const reference = mountBox({ left: '20px', top: '30px', width: '40px', height: '20px' }); + const overlay = mountBox({ left: '200px', top: '100px', width: '240px', height: '120px' }); + overlay.style.translate = + 'calc(var(--motion-x, 0px) + var(--flip-x, 0px)) calc(var(--motion-y, 0px) + var(--flip-y, 0px))'; + overlay.style.scale = 'var(--flip-scale-x, 1) var(--flip-scale-y, 1)'; + overlay.style.setProperty('--flip-x', '-180px'); + overlay.style.setProperty('--flip-y', '-70px'); + overlay.style.setProperty('--flip-scale-x', `${40 / 240}`); + overlay.style.setProperty('--flip-scale-y', `${20 / 120}`); + const transition = anchoredFlip( + () => true, + () => reference, + { duration: 30 } + ); + + const cleanup = transition(overlay); + await settleAnimations(overlay); + + expectRestingVisualState(overlay); + cleanup?.(); + }); + + it('restores the open visual state when an exit is interrupted by a new enter', async () => { + const reference = mountBox({ left: '20px', top: '30px', width: '40px', height: '20px' }); + const overlay = mountBox({ left: '200px', top: '100px', width: '240px', height: '120px' }); + let open = true; + const transition = anchoredFlip( + () => open, + () => reference, + { duration: 150 } + ); + + let cleanup = transition(overlay); + await settleAnimations(overlay); + + open = false; + cleanup?.(); + cleanup = transition(overlay); + await new Promise((resolve) => requestAnimationFrame(resolve)); + + open = true; + cleanup?.(); + cleanup = transition(overlay); + await settleAnimations(overlay); + + expectRestingVisualState(overlay); + expect(overlay.style.opacity).toBe(''); + cleanup?.(); + }); + + it('does nothing for an initially closed mounted element', () => { + const overlay = mountBox({ left: '200px', top: '100px', width: '240px', height: '120px' }); + let referenceReads = 0; + const transition = anchoredFlip( + () => false, + () => { + referenceReads++; + return null; + } + ); + + const cleanup = transition(overlay); + + expect(referenceReads).toBe(0); + expect(overlay.getAnimations()).toHaveLength(0); + expectRestingVisualState(overlay); + cleanup?.(); + }); + + it('settles closed without geometry when the reference disappears before exit', async () => { + const reference = mountBox({ left: '20px', top: '30px', width: '40px', height: '20px' }); + const overlay = mountBox({ left: '200px', top: '100px', width: '240px', height: '120px' }); + let open = true; + let currentReference: Element | null = reference; + const transition = anchoredFlip( + () => open, + () => currentReference, + { duration: 150 } + ); + + let cleanup = transition(overlay); + await settleAnimations(overlay); + + currentReference = null; + open = false; + cleanup?.(); + cleanup = transition(overlay); + + expect(overlay.getAnimations()).toHaveLength(0); + expect(getComputedStyle(overlay).opacity).toBe('0'); + expect(overlay.getBoundingClientRect().width).toBeCloseTo(240, 0); + cleanup?.(); + }); + + it('cancels safely and restores styles when the reference disappears', async () => { + const reference = mountBox({ left: '20px', top: '30px', width: '40px', height: '20px' }); + const overlay = mountBox({ left: '200px', top: '100px', width: '240px', height: '120px' }); + let open = true; + let currentReference: Element | null = reference; + const transition = anchoredFlip( + () => open, + () => currentReference, + { duration: 150 } + ); + + let cleanup = transition(overlay); + await settleAnimations(overlay); + + open = false; + cleanup?.(); + cleanup = transition(overlay); + await new Promise((resolve) => requestAnimationFrame(resolve)); + + currentReference = null; + open = true; + cleanup?.(); + expect(() => { + cleanup = transition(overlay); + }).not.toThrow(); + + expect(overlay.getAnimations()).toHaveLength(0); + expectRestingVisualState(overlay); + cleanup?.(); + }); + + it('restores pre-existing inline styles when destroyed during a transition', async () => { + const reference = mountBox({ left: '20px', top: '30px', width: '40px', height: '20px' }); + const overlay = mountBox({ + left: '200px', + top: '100px', + width: '240px', + height: '120px', + opacity: '0.75', + pointerEvents: 'auto' + }); + const transition = anchoredFlip( + () => true, + () => reference, + { + duration: 200, + disablePointerEvents: true + } + ); + + const cleanup = transition(overlay); + await new Promise((resolve) => requestAnimationFrame(resolve)); + cleanup?.(); + + expect(overlay.getAnimations()).toHaveLength(0); + expect(overlay.style.opacity).toBe('0.75'); + expect(overlay.style.pointerEvents).toBe('auto'); + for (const property of FLIP_PROPERTIES) { + expect(overlay.style.getPropertyValue(property)).toBe(''); + } + }); + + it('reaches the closed anchor state when enter is interrupted by exit', async () => { + const reference = mountBox({ left: '20px', top: '30px', width: '40px', height: '20px' }); + const overlay = mountBox({ left: '200px', top: '100px', width: '240px', height: '120px' }); + let open = true; + const transition = anchoredFlip( + () => open, + () => reference, + { duration: 150 } + ); + + let cleanup = transition(overlay); + await new Promise((resolve) => requestAnimationFrame(resolve)); + + open = false; + cleanup?.(); + cleanup = transition(overlay); + await settleAnimations(overlay); + + const rect = overlay.getBoundingClientRect(); + expect(rect.x).toBeCloseTo(20, 0); + expect(rect.y).toBeCloseTo(30, 0); + expect(rect.width).toBeCloseTo(40, 0); + expect(rect.height).toBeCloseTo(20, 0); + expect(getComputedStyle(overlay).opacity).toBe('0'); + cleanup?.(); + }); + + it('settles immediately in valid open and closed states under reduced motion', async () => { + const originalMatchMedia = window.matchMedia; + window.matchMedia = vi.fn().mockReturnValue({ + matches: true, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + }) as unknown as typeof window.matchMedia; + vi.resetModules(); + + try { + const { anchoredFlip: reducedAnchoredFlip } = await import('./index'); + const reference = mountBox({ left: '20px', top: '30px', width: '40px', height: '20px' }); + const overlay = mountBox({ left: '200px', top: '100px', width: '240px', height: '120px' }); + let open = true; + const transition = reducedAnchoredFlip( + () => open, + () => reference, + { duration: 500 } + ); + + let cleanup = transition(overlay); + expect(overlay.getAnimations()).toHaveLength(0); + expectRestingVisualState(overlay); + + open = false; + cleanup?.(); + cleanup = transition(overlay); + expect(overlay.getAnimations()).toHaveLength(0); + expect(getComputedStyle(overlay).opacity).toBe('0'); + expect(overlay.getBoundingClientRect().width).toBeCloseTo(240, 0); + + open = true; + cleanup?.(); + cleanup = transition(overlay); + expectRestingVisualState(overlay); + cleanup?.(); + } finally { + window.matchMedia = originalMatchMedia; + vi.resetModules(); + } + }); + + it('accepts a virtual reference element', async () => { + const overlay = mountBox({ left: '200px', top: '100px', width: '240px', height: '120px' }); + const reference = { + getBoundingClientRect: () => ({ left: 20, top: 30, width: 40, height: 20 }) + }; + const transition = anchoredFlip( + () => true, + () => reference, + { duration: 30 } + ); + + const cleanup = transition(overlay); + await settleAnimations(overlay); + + expectRestingVisualState(overlay); + cleanup?.(); + }); +}); diff --git a/src/lib/flip/anchored.test.ts b/src/lib/flip/anchored.test.ts new file mode 100644 index 0000000..953eb86 --- /dev/null +++ b/src/lib/flip/anchored.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it, vi } from 'vitest'; +import { anchoredFlip } from './index'; + +describe('anchoredFlip() — non-browser environment', () => { + it('does not read state or geometry during SSR', () => { + const isOpen = vi.fn(() => true); + const getReference = vi.fn(() => null); + const transition = anchoredFlip(isOpen, getReference); + + expect(() => transition({} as HTMLElement)).not.toThrow(); + expect(isOpen).not.toHaveBeenCalled(); + expect(getReference).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/flip/anchored.ts b/src/lib/flip/anchored.ts new file mode 100644 index 0000000..e39770c --- /dev/null +++ b/src/lib/flip/anchored.ts @@ -0,0 +1,163 @@ +import type { AnimationController } from '../animate/types'; +import { saveStyleProp, restoreStyleProp, type SavedStyleProp } from '../shared/inline-style'; +import { isBrowser } from '../shared/browser'; +import { animateFlip } from './animator'; +import { measure } from './geometry'; +import { resolveOpacity } from './options'; +import type { FlipOptions, FlipRect, MotionElement } from './types'; + +const FLIP_STYLE_PROPERTIES = ['--flip-x', '--flip-y', '--flip-scale-x', '--flip-scale-y'] as const; +const OWNED_STYLE_PROPERTIES = [...FLIP_STYLE_PROPERTIES, 'opacity', 'pointer-events'] as const; + +type OwnedStyleProperty = (typeof OWNED_STYLE_PROPERTIES)[number]; +type StyleSnapshot = Record; + +/** A DOM element or virtual anchor that can provide viewport geometry. */ +export interface AnchoredFlipReference { + getBoundingClientRect(): Pick; +} + +export type AnchoredFlipReferenceGetter = () => AnchoredFlipReference | null | undefined; + +/** Animation options shared by the anchored enter and exit transitions. */ +export type AnchoredFlipOptions = Omit; + +/** Framework-neutral lifecycle function suitable for actions, attachments, and node hooks. */ +export type AnchoredFlipNodeFunction = (element: MotionElement) => (() => void) | void; + +const snapshotStyles = (element: MotionElement): StyleSnapshot => + Object.fromEntries( + OWNED_STYLE_PROPERTIES.map((property) => [ + property, + property.startsWith('--flip-') + ? { value: '', priority: '' } + : saveStyleProp(element.style, property) + ]) + ) as StyleSnapshot; + +const restoreStyles = (element: MotionElement, snapshot: StyleSnapshot): void => { + for (const property of OWNED_STYLE_PROPERTIES) { + restoreStyleProp(element.style, property, snapshot[property]); + } +}; + +const measureReference = (reference: AnchoredFlipReference): FlipRect => { + if (reference instanceof Element) return measure(reference); + const { left: x, top: y, width, height } = reference.getBoundingClientRect(); + return { x, y, width, height }; +}; + +/** + * Create a lifecycle-safe FLIP transition between a mounted element and an + * anchor. Invoke the returned node function whenever `isOpen()` changes and + * run its cleanup before the next invocation or when the node is destroyed. + * + * The transition owns cancellation and its temporary FLIP, opacity, and + * pointer-event styles. Enter always clears a previous exit's committed FLIP + * state before measuring the mounted element. + */ +export const anchoredFlip = ( + isOpen: () => boolean, + getReference: AnchoredFlipReferenceGetter, + options: AnchoredFlipOptions = {} +): AnchoredFlipNodeFunction => { + let element: MotionElement | null = null; + let initialStyles: StyleSnapshot | null = null; + let active: AnimationController | null = null; + let generation = 0; + let hasOpened = false; + + const reset = (): void => { + if (element && initialStyles) restoreStyles(element, initialStyles); + }; + + const cancelActive = (): void => { + const controller = active; + active = null; + controller?.cancel(); + }; + + return (node) => { + if (!isBrowser()) return; + + if (element !== node) { + generation++; + cancelActive(); + reset(); + element = node; + initialStyles = snapshotStyles(node); + hasOpened = false; + } + + const token = ++generation; + cancelActive(); + // A completed forward FLIP intentionally holds its closed visual state. + // Remove it before any new geometry read, especially a subsequent enter. + reset(); + + const open = isOpen(); + const cleanup = (): void => { + if (token !== generation || element !== node) return; + generation++; + cancelActive(); + reset(); + }; + + if (!open && !hasOpened) return cleanup; + + const reference = getReference(); + if (!reference || typeof reference.getBoundingClientRect !== 'function') { + if (!open) { + const resolvedOpacity = resolveOpacity(options.opacity ?? true); + if (resolvedOpacity) node.style.opacity = String(resolvedOpacity.from); + } + return cleanup; + } + + const referenceRect = measureReference(reference); + const overlayRect = measure(node); + if (open) hasOpened = true; + + if (options.disablePointerEvents) node.style.pointerEvents = 'none'; + + const { onEnd } = options; + const opacity = options.opacity ?? true; + const controller = animateFlip({ + element: node, + from: open ? referenceRect : overlayRect, + to: open ? overlayRect : referenceRect, + forward: !open, + options: { + ...options, + disablePointerEvents: false, + opacity, + onEnd: (finishedElement, info) => { + try { + onEnd?.(finishedElement, info); + } finally { + if (token === generation && element === node) { + active = null; + if (initialStyles) { + restoreStyleProp(node.style, 'pointer-events', initialStyles['pointer-events']); + if (open) restoreStyles(node, initialStyles); + } + } + } + } + } + }); + + active = controller; + if (!controller && initialStyles) { + restoreStyleProp(node.style, 'pointer-events', initialStyles['pointer-events']); + if (open) { + restoreStyles(node, initialStyles); + } else { + const resolvedOpacity = resolveOpacity(opacity); + if (resolvedOpacity) node.style.opacity = String(resolvedOpacity.from); + } + } + + return cleanup; + }; +}; diff --git a/src/lib/flip/animation/README.md b/src/lib/flip/animation/README.md deleted file mode 100644 index c6e3fa0..0000000 --- a/src/lib/flip/animation/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# flip/animation - -Transform execution and lifecycle for [`flip`](../README.md). Given a `from` and `to` rect, this layer computes the inverse transform, plays it, and manages cancellation. - -| File | Responsibility | -| --- | --- | -| `animator.ts` | `animateFlip(args)` — the stateless core animator. Computes the delta between `from` and `to`, compensates for transform-origin, animates through motion custom properties (`--motion-x`, `--motion-y`, `--motion-scale-x`, `--motion-scale-y`), and optionally crossfades opacity. Returns an `AnimationController` or `null` when there's no work to do. | -| `cancel-controller.ts` | `cancelController(controller)` — safely cancel an in-flight animation, swallowing errors if it was already torn down. | -| `controller-slot.ts` | `createControllerSlot()` — a single-slot holder for the current FLIP controller. Cancels the previous animation when a new one starts and auto-clears when one finishes naturally, centralizing the lifecycle boilerplate. | - -`animateFlip` runs through the same `--motion-*` properties as [`$lib/animate`](../../animate/README.md), so a FLIP and a sibling `animate()` compose without overwriting each other's transform. diff --git a/src/lib/flip/animation/cancel-controller.test.ts b/src/lib/flip/animation/cancel-controller.test.ts deleted file mode 100644 index 618e8e0..0000000 --- a/src/lib/flip/animation/cancel-controller.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Tests for cancelController() — the defensive cancel wrapper. - * No DOM access — runs in the `server` vitest project. - */ - -import { describe, expect, it, vi } from 'vitest'; -import { cancelController } from './cancel-controller'; -import type { AnimationController } from '$lib/animate/types'; - -const controller = (cancel: () => void): AnimationController => - ({ cancel }) as unknown as AnimationController; - -describe('cancelController()', () => { - it('no-ops when given null', () => { - expect(() => cancelController(null)).not.toThrow(); - }); - - it('calls cancel() on a live controller', () => { - const cancel = vi.fn(); - cancelController(controller(cancel)); - expect(cancel).toHaveBeenCalledTimes(1); - }); - - it('swallows errors thrown by cancel() (already torn down)', () => { - const cancel = vi.fn(() => { - throw new Error('already cancelled'); - }); - expect(() => cancelController(controller(cancel))).not.toThrow(); - expect(cancel).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/lib/flip/animation/cancel-controller.ts b/src/lib/flip/animation/cancel-controller.ts deleted file mode 100644 index 29b8c47..0000000 --- a/src/lib/flip/animation/cancel-controller.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { AnimationController } from '$lib/animate/types'; - -/** - * Cancel an in-flight animation controller, ignoring errors thrown when the - * underlying animations have already been torn down (e.g. during cleanup). - */ -export const cancelController = (controller: AnimationController | null): void => { - if (!controller) return; - try { - controller.cancel(); - } catch { - // noop — animation may already be torn down - } -}; diff --git a/src/lib/flip/animation/controller-slot.ts b/src/lib/flip/animation/controller-slot.ts deleted file mode 100644 index 1e9370a..0000000 --- a/src/lib/flip/animation/controller-slot.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * A single-slot holder for the in-flight FLIP controller. - * - * Both the `flip()` attachment and the switcher run at most one animation per - * element at a time: starting a new run cancels the previous one, and the slot - * auto-clears when an animation settles naturally. Centralizing that lifecycle - * keeps the attachment files declarative and removes the duplicated - * cancel / wrap-onEnd / null-on-finish boilerplate. - */ - -import type { AnimationController } from '$lib/animate/types'; -import { animateFlip } from './animator'; -import { cancelController } from './cancel-controller'; -import type { FlipAnimateArgs } from '../types'; - -interface ControllerSlot { - /** Cancel any in-flight controller, then start a new FLIP animation. */ - run: (args: FlipAnimateArgs) => void; - /** Whether a FLIP animation is currently in flight. */ - isActive: () => boolean; - /** Cancel the current controller and empty the slot (teardown). */ - cancel: () => void; -} - -export const createControllerSlot = (): ControllerSlot => { - let current: AnimationController | null = null; - - return { - run: ({ options, ...rest }) => { - cancelController(current); - current = animateFlip({ - ...rest, - options: { - ...options, - // Clear the slot only on a natural finish — a cancel (finished: - // false) is always followed by a fresh assignment below, so guarding - // on `finished` prevents the outgoing run from nulling the new one. - onEnd: (el, info) => { - if (info.finished) current = null; - options?.onEnd?.(el, info); - } - } - }); - }, - isActive: () => current !== null, - cancel: () => { - cancelController(current); - current = null; - } - }; -}; diff --git a/src/lib/flip/animator.svelte.test.ts b/src/lib/flip/animator.svelte.test.ts new file mode 100644 index 0000000..91721b4 --- /dev/null +++ b/src/lib/flip/animator.svelte.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { animateFlip } from './animator'; +import { measure } from './geometry'; + +let element: HTMLDivElement | null = null; + +afterEach(() => { + element?.remove(); + element = null; +}); + +describe('forward FLIP opacity', () => { + it('reverses crossfade endpoints for a forward exit', async () => { + element = document.createElement('div'); + Object.assign(element.style, { + position: 'fixed', + left: '200px', + top: '100px', + width: '240px', + height: '120px' + }); + document.body.appendChild(element); + + const controller = animateFlip({ + element, + from: measure(element), + to: { x: 20, y: 30, width: 40, height: 20 }, + options: { duration: 30, opacity: { from: 0.2, to: 0.8 } }, + forward: true + }); + + expect(controller).not.toBeNull(); + await controller?.finished; + expect(getComputedStyle(element).opacity).toBe('0.2'); + }); +}); diff --git a/src/lib/flip/animation/animator.test.ts b/src/lib/flip/animator.test.ts similarity index 96% rename from src/lib/flip/animation/animator.test.ts rename to src/lib/flip/animator.test.ts index df32c42..4a3a91a 100644 --- a/src/lib/flip/animation/animator.test.ts +++ b/src/lib/flip/animator.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest'; import { animateFlip } from './animator'; -import type { FlipRect } from '../types'; +import type { FlipRect } from './types'; const rect = (x = 0, y = 0, w = 100, h = 50): FlipRect => ({ x, diff --git a/src/lib/flip/animation/animator.ts b/src/lib/flip/animator.ts similarity index 84% rename from src/lib/flip/animation/animator.ts rename to src/lib/flip/animator.ts index 4f1cdd6..f244adc 100644 --- a/src/lib/flip/animation/animator.ts +++ b/src/lib/flip/animator.ts @@ -16,14 +16,14 @@ * the same controller so a single `cancel()` aborts the whole effect. */ -import { animate } from '$lib/animate/core/animate'; -import { buildFlipProps } from '$lib/animate/flip'; -import type { AnimationController, MotionElement } from '$lib/animate/types'; -import { isIdentityDelta, rectsEqual, resolveFlipDelta } from '../geometry'; -import type { FlipDelta } from '../geometry'; -import { isBrowser, shouldReduceMotion } from '$lib/shared/browser'; -import { DEFAULT_DELAY, resolveDuration, resolveEasing, resolveOpacity } from '../options'; -import type { FlipAnimateArgs } from '../types'; +import { animate } from '../animate/core/animate'; +import type { AnimationController, MotionElement } from '../animate/types'; +import { buildFlipProps, isIdentityDelta, rectsEqual, resolveFlipDelta } from './geometry'; +import type { FlipDelta } from './geometry'; +import { isBrowser, shouldReduceMotion } from '../shared/browser'; +import { restoreStyleProp, saveStyleProp } from '../shared/inline-style'; +import { DEFAULT_DELAY, resolveDuration, resolveEasing, resolveOpacity } from './options'; +import type { FlipAnimateArgs } from './types'; /** * Parse a CSS `transform-origin` computed value (always in `px` in computed @@ -49,11 +49,9 @@ const suppressPointerEvents = ( enabled: boolean | undefined ): (() => void) => { if (!enabled) return () => {}; - const previous = element.style.pointerEvents; - element.style.pointerEvents = 'none'; - return () => { - element.style.pointerEvents = previous; - }; + const previous = saveStyleProp(element.style, 'pointer-events'); + element.style.setProperty('pointer-events', 'none'); + return () => restoreStyleProp(element.style, 'pointer-events', previous); }; /** @@ -121,7 +119,7 @@ export const animateFlip = ({ // CSS variable so this never collides with concurrent animate() calls. const props = buildFlipProps(delta, forward); if (opacity) { - props.opacity = [opacity.from, opacity.to]; + props.opacity = forward ? [opacity.to, opacity.from] : [opacity.from, opacity.to]; } const restorePointerEvents = suppressPointerEvents(element, options.disablePointerEvents); diff --git a/src/lib/flip/attachment.svelte.test.ts b/src/lib/flip/attachment.svelte.test.ts new file mode 100644 index 0000000..ef2596e --- /dev/null +++ b/src/lib/flip/attachment.svelte.test.ts @@ -0,0 +1,108 @@ +import { flushSync } from 'svelte'; +import { afterEach, describe, expect, it } from 'vitest'; +import { flip } from './index'; +import type { FlipOptions } from './types'; + +let mounted: Element[] = []; +let roots: Array<() => void> = []; + +const mountBox = (): HTMLDivElement => { + const element = document.createElement('div'); + Object.assign(element.style, { + position: 'fixed', + top: '0px', + left: '20px', + width: '40px', + height: '20px' + }); + document.body.appendChild(element); + mounted.push(element); + return element; +}; + +/** Attach `flip()` inside an effect root so its `$effect`s run under `flushSync`. */ +const attach = (element: HTMLElement, options: () => FlipOptions): void => { + // Returning the attachment's cleanup makes the root's dispose tear it down, + // the way Svelte's attachment machinery does. + const dispose = $effect.root(() => flip(options)(element)); + roots.push(dispose); + flushSync(); +}; + +const settleAnimations = async (element: Element): Promise => { + await Promise.all( + element.getAnimations().map((animation) => animation.finished.catch(() => undefined)) + ); + await Promise.resolve(); +}; + +afterEach(() => { + for (const dispose of roots) dispose(); + roots = []; + for (const element of mounted) element.remove(); + mounted = []; +}); + +describe('flip() reactive class/style', () => { + it('applies a style change and animates the resulting layout shift', async () => { + const element = mountBox(); + let open = $state(false); + attach(element, () => ({ + duration: 40, + style: () => (open ? 'left: 200px' : 'left: 20px') + })); + + expect(element.getAnimations()).toHaveLength(0); + + open = true; + flushSync(); + + expect(element.style.left).toBe('200px'); + expect(element.getAnimations().length).toBeGreaterThan(0); + + await settleAnimations(element); + expect(element.getBoundingClientRect().x).toBeCloseTo(200, 0); + expect(element.style.getPropertyValue('--motion-x')).toBe(''); + }); + + it('applies the initial class before the first measure, so mounting does not animate', () => { + const element = mountBox(); + attach(element, () => ({ duration: 40, class: () => ({ 'is-open': true }) })); + + expect(element.classList.contains('is-open')).toBe(true); + expect(element.getAnimations()).toHaveLength(0); + }); + + it('counts only cycles that move, so skip sees the first real change', async () => { + const element = mountBox(); + let open = $state(false); + const renders: number[] = []; + attach(element, () => ({ + duration: 40, + skip: (render) => { + renders.push(render); + return render === 0; + }, + style: () => (open ? 'left: 200px' : 'left: 20px') + })); + + open = true; + flushSync(); + + expect(renders).toEqual([0]); + expect(element.getAnimations()).toHaveLength(0); + await settleAnimations(element); + }); + + it('removes the classes it applied on teardown', () => { + const element = mountBox(); + element.classList.add('from-markup'); + attach(element, () => ({ class: () => 'is-open' })); + expect(element.classList.contains('is-open')).toBe(true); + + for (const dispose of roots) dispose(); + roots = []; + + expect([...element.classList]).toEqual(['from-markup']); + }); +}); diff --git a/src/lib/flip/integration/attachment.svelte.ts b/src/lib/flip/attachment.svelte.ts similarity index 54% rename from src/lib/flip/integration/attachment.svelte.ts rename to src/lib/flip/attachment.svelte.ts index 08af591..4e45336 100644 --- a/src/lib/flip/integration/attachment.svelte.ts +++ b/src/lib/flip/attachment.svelte.ts @@ -9,14 +9,15 @@ import { untrack } from 'svelte'; import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '$lib/shared/browser'; +import { isBrowser } from '../shared/browser'; +import { createFrameBatch } from '../shared/frame-batch'; import type { LayoutBridge } from './bridge'; -import { createControllerSlot } from '../animation/controller-slot'; -import { measure, measureVisual, rectsEqual } from '../geometry'; -import type { ObserverManager } from '../tracking/observer-manager'; -import { readOptions } from '../options'; -import { createReflowScheduler } from '../tracking/scheduler'; -import type { FlipAuto, FlipOptions, FlipOptionsInput, FlipRect, MotionElement } from '../types'; +import { createDynamicAttrs } from './dynamic'; +import { createControllerSlot } from './controller-slot'; +import { carryVisualOffset, measure, measureVisual, rectsEqual } from './geometry'; +import { createObserverManager } from './observers'; +import { readOptions } from './options'; +import type { FlipOptions, FlipOptionsInput, FlipRect, MotionElement } from './types'; /** * Core FLIP attachment factory. Shared between the standalone `flip()` @@ -30,7 +31,6 @@ export const createFlipAttachment = ( if (!isBrowser()) return; let options: FlipOptions = untrack(() => readOptions(input)); - let auto: FlipAuto | undefined = options.auto; let layoutId = options.layoutId; let prevRect: FlipRect | null = layoutId && bridge ? bridge.readLayout(layoutId) : null; let renderCount = 0; @@ -46,44 +46,53 @@ export const createFlipAttachment = ( const reflow = (): void => { if (!prevRect) return; const next = measure(element); + // Count only cycles that would actually animate — observers deliver a + // callback on connect, and `skip: (n) => n === 0` must land on the first + // real move rather than that no-op. + if (rectsEqual(prevRect, next)) { + prevRect = next; + return; + } const render = renderCount++; const { skip } = options; const shouldSkip = typeof skip === 'function' ? skip(render, { from: prevRect, to: next }) : (skip ?? false); - if (shouldSkip || rectsEqual(prevRect, next)) { + if (shouldSkip) { prevRect = next; return; } - // Interrupting an in-flight FLIP: start from the element's live on-screen - // rect (transform included) so the replacement run continues from where it - // visually is, instead of snapping back to its resting box first. When idle - // the element already sits at its new resting box, so the captured `from` - // must be the previous rect to produce any movement. - const from = slot.isActive() ? measureVisual(element) : prevRect; + // Interrupting an in-flight FLIP: continue from where the element was on + // screen *before* this layout change. The live transform is an offset from + // the element's old resting box, so the visual rect measured now (against + // the new box) has to be carried back onto `prevRect` — using it raw would + // add the offset to the new slot and fling the element out of place. + const from = slot.isActive() + ? carryVisualOffset(prevRect, next, measureVisual(element)) + : prevRect; prevRect = next; run(from, next); }; - const scheduler = createReflowScheduler(reflow); - let connectedManager: ObserverManager | null = null; - - const syncObservers = (): void => { - connectedManager?.disconnect(); - connectedManager = null; - if (typeof auto === 'object') { - auto.connect(element, scheduler.schedule); - connectedManager = auto; + // The first scheduled pass after mount only re-baselines: layout is not + // settled when the attachment runs (siblings still mounting, fonts, images + // loading), so a diff on that pass is the page settling, not a move worth + // animating. + let primed = false; + const scheduler = createFrameBatch(() => { + if (!primed) { + primed = true; + prevRect = measure(element); + return; } - }; + reflow(); + }); + const observers = createObserverManager(); + const attrs = createDynamicAttrs(element); // ----------------------------------------------------------------- // Track option changes (when caller passes a thunk). // ----------------------------------------------------------------- - let autoEffectVersion = $state(0); - const applyOptions = (next: FlipOptions): void => { - const autoChanged = next.auto !== auto; - options = next; if (next.layoutId !== layoutId) { @@ -91,25 +100,18 @@ export const createFlipAttachment = ( const restored = layoutId && bridge ? bridge.readLayout(layoutId) : null; prevRect = restored ?? measure(element); } - - if (autoChanged) { - auto = next.auto; - // Use untrack so the read of autoEffectVersion is not registered as a - // dependency of the enclosing $effect. Without this, `+= 1` would both - // read *and* write the signal inside the same effect, causing Svelte to - // immediately re-schedule the effect and loop until depth is exceeded. - autoEffectVersion = untrack(() => autoEffectVersion) + 1; - syncObservers(); - } }; // ----------------------------------------------------------------- // Initial enter animation (when restored from a shared-layout entry). + // Attributes are applied *before* the first measure so mounting in the + // styled state does not animate out of the unstyled one. // ----------------------------------------------------------------- + untrack(() => attrs.write(options.class?.(), options.style?.())); const initialRect = measure(element); if (prevRect) run(prevRect, initialRect); prevRect = initialRect; - syncObservers(); + observers.connect(element, scheduler.schedule); if (typeof input === 'function') { $effect(() => { @@ -118,13 +120,18 @@ export const createFlipAttachment = ( }); } - // Re-run the user's auto thunk whenever its tracked dependencies change. + // Reactive class/style. Reads the options thunk directly rather than the + // closed-over `options` (a plain `let`, not a signal) so a changed + // class/style thunk identity is picked up without extra bookkeeping. $effect(() => { - void autoEffectVersion; - const trigger = options.auto; - if (typeof trigger !== 'function') return; - trigger(); - scheduler.schedule(); + const current = typeof input === 'function' ? readOptions(input) : options; + const classValue = current.class?.(); + const styleValue = current.style?.(); + // Write and measure untracked — reflow() reads geometry and reassigns + // state that must not become a dependency of this effect. + untrack(() => { + if (attrs.write(classValue, styleValue)) reflow(); + }); }); // ----------------------------------------------------------------- @@ -132,9 +139,10 @@ export const createFlipAttachment = ( // ----------------------------------------------------------------- return () => { scheduler.cancel(); - connectedManager?.disconnect(); + observers.disconnect(); if (layoutId && bridge && prevRect) bridge.writeLayout(layoutId, prevRect); slot.cancel(); + attrs.reset(); }; }; }; diff --git a/src/lib/flip/integration/bridge.test.ts b/src/lib/flip/bridge.test.ts similarity index 51% rename from src/lib/flip/integration/bridge.test.ts rename to src/lib/flip/bridge.test.ts index 72937a4..a8debd0 100644 --- a/src/lib/flip/integration/bridge.test.ts +++ b/src/lib/flip/bridge.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'; import { createLayoutBridge } from './bridge'; -import type { FlipRect } from '../types'; +import type { FlipRect } from './types'; const rect = (x = 0, y = 0, w = 100, h = 50): FlipRect => ({ x, @@ -15,55 +15,44 @@ const rect = (x = 0, y = 0, w = 100, h = 50): FlipRect => ({ }); describe('createLayoutBridge()', () => { - it('returns an object with bridge and clear', () => { - const { bridge, clear } = createLayoutBridge(); + it('returns a read/write pair', () => { + const bridge = createLayoutBridge(); expect(typeof bridge.writeLayout).toBe('function'); expect(typeof bridge.readLayout).toBe('function'); - expect(typeof clear).toBe('function'); }); it('readLayout returns null for an unknown id', () => { - const { bridge } = createLayoutBridge(); + const bridge = createLayoutBridge(); expect(bridge.readLayout('unknown')).toBeNull(); }); it('writeLayout + readLayout round-trips the rect', () => { - const { bridge } = createLayoutBridge(); + const bridge = createLayoutBridge(); const r = rect(10, 20, 300, 150); bridge.writeLayout('hero', r); expect(bridge.readLayout('hero')).toEqual(r); }); - it('readLayout deletes the entry on expiry', () => { + it('readLayout returns the rect while still within the 250ms TTL', () => { vi.useFakeTimers(); - const { bridge } = createLayoutBridge(100); - bridge.writeLayout('card', rect()); - // Advance past TTL - vi.advanceTimersByTime(150); - expect(bridge.readLayout('card')).toBeNull(); - vi.useRealTimers(); - }); - - it('readLayout returns the rect while still within TTL', () => { - vi.useFakeTimers(); - const { bridge } = createLayoutBridge(200); + const bridge = createLayoutBridge(); bridge.writeLayout('card', rect(1, 2, 3, 4)); - vi.advanceTimersByTime(100); + vi.advanceTimersByTime(200); expect(bridge.readLayout('card')).toEqual(rect(1, 2, 3, 4)); vi.useRealTimers(); }); - it('clear() removes all stored entries', () => { - const { bridge, clear } = createLayoutBridge(); - bridge.writeLayout('a', rect()); - bridge.writeLayout('b', rect(5, 5)); - clear(); - expect(bridge.readLayout('a')).toBeNull(); - expect(bridge.readLayout('b')).toBeNull(); + it('readLayout deletes the entry once the TTL has passed', () => { + vi.useFakeTimers(); + const bridge = createLayoutBridge(); + bridge.writeLayout('card', rect()); + vi.advanceTimersByTime(251); + expect(bridge.readLayout('card')).toBeNull(); + vi.useRealTimers(); }); it('writeLayout overwrites a previously stored rect', () => { - const { bridge } = createLayoutBridge(); + const bridge = createLayoutBridge(); bridge.writeLayout('id', rect(0, 0, 100, 50)); const newer = rect(99, 99, 200, 200); bridge.writeLayout('id', newer); @@ -73,18 +62,7 @@ describe('createLayoutBridge()', () => { it('separate bridges are independent', () => { const a = createLayoutBridge(); const b = createLayoutBridge(); - a.bridge.writeLayout('shared', rect(1, 2, 3, 4)); - expect(b.bridge.readLayout('shared')).toBeNull(); - }); - - it('custom ttlMs is respected', () => { - vi.useFakeTimers(); - const { bridge } = createLayoutBridge(50); - bridge.writeLayout('x', rect()); - vi.advanceTimersByTime(49); - expect(bridge.readLayout('x')).not.toBeNull(); - vi.advanceTimersByTime(2); - expect(bridge.readLayout('x')).toBeNull(); - vi.useRealTimers(); + a.writeLayout('shared', rect(1, 2, 3, 4)); + expect(b.readLayout('shared')).toBeNull(); }); }); diff --git a/src/lib/flip/bridge.ts b/src/lib/flip/bridge.ts new file mode 100644 index 0000000..f74956b --- /dev/null +++ b/src/lib/flip/bridge.ts @@ -0,0 +1,43 @@ +/** + * Layout bridge for shared-element transitions. + * + * Stores the last-known rect for a `layoutId` so a sibling mounting just after + * another unmounts can read the position without incurring a global registry. + * Records expire after {@link LAYOUT_TTL_MS} — a handoff happens within a frame + * or two, so anything older is stale by definition. + */ + +import type { FlipRect } from './types'; + +interface LayoutRecord { + rect: FlipRect; + timestamp: number; +} + +export interface LayoutBridge { + readLayout: (id: string) => FlipRect | null; + writeLayout: (id: string, rect: FlipRect) => void; +} + +/** Lifetime of an unread layout record, in ms. */ +const LAYOUT_TTL_MS = 250; + +/** Create an in-memory, scope-local layout registry. */ +export const createLayoutBridge = (): LayoutBridge => { + const registry = new Map(); + + return { + writeLayout: (id, rect) => { + registry.set(id, { rect, timestamp: performance.now() }); + }, + readLayout: (id) => { + const entry = registry.get(id); + if (!entry) return null; + if (performance.now() - entry.timestamp > LAYOUT_TTL_MS) { + registry.delete(id); + return null; + } + return entry.rect; + } + }; +}; diff --git a/src/lib/flip/burst.svelte.test.ts b/src/lib/flip/burst.svelte.test.ts new file mode 100644 index 0000000..9ca6046 --- /dev/null +++ b/src/lib/flip/burst.svelte.test.ts @@ -0,0 +1,80 @@ +/** + * Rapid re-trigger behaviour: reordering faster than a FLIP settles must keep + * every item inside the row it belongs to. An in-flight transform is an offset + * from the *old* resting box, so an interrupting run that treats the live + * visual rect as its `from` flings the element a full slot out of place. + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { flip } from './index'; + +const GAP = 10; +const WIDTH = 40; +const SLOT = WIDTH + GAP; +const COUNT = 5; + +let dispose: (() => void) | null = null; +let container: HTMLDivElement | null = null; + +const frame = (): Promise => new Promise((r) => requestAnimationFrame(() => r())); +/** rAF then a task tick — samples what the frame actually painted, after every + * rAF callback (including flip's frame batch) has run. */ +const paintedFrame = async (): Promise => { + await frame(); + await new Promise((r) => setTimeout(r, 0)); +}; + +afterEach(() => { + dispose?.(); + dispose = null; + container?.remove(); + container = null; +}); + +describe('reordering faster than the animation settles', () => { + it('never pushes an item outside the row, and lands on the layout slots', async () => { + container = document.createElement('div'); + Object.assign(container.style, { + position: 'fixed', + top: '0px', + left: '0px', + display: 'flex', + gap: `${GAP}px` + }); + document.body.appendChild(container); + + const boxes = Array.from({ length: COUNT }, () => { + const box = document.createElement('div'); + Object.assign(box.style, { width: `${WIDTH}px`, height: '20px' }); + container!.appendChild(box); + return box; + }); + + dispose = $effect.root(() => { + for (const box of boxes) flip({ duration: 300 })(box); + }); + await paintedFrame(); + + const lastSlot = (COUNT - 1) * SLOT; + const positions = (): number[] => boxes.map((b) => b.getBoundingClientRect().x); + const offRow: number[] = []; + + // Rotate the row every other frame — far faster than any run settles. + for (let i = 0; i < 6; i++) { + container.appendChild(container.firstElementChild!); + for (let f = 0; f < 2; f++) { + await paintedFrame(); + offRow.push(...positions().filter((x) => x < -1 || x > lastSlot + 1)); + } + } + expect(offRow).toEqual([]); + + await Promise.all( + boxes.flatMap((b) => b.getAnimations().map((a) => a.finished.catch(() => undefined))) + ); + await paintedFrame(); + + const resting = [...container.children].map((c) => c.getBoundingClientRect().x); + expect(resting).toEqual(Array.from({ length: COUNT }, (_, i) => i * SLOT)); + }); +}); diff --git a/src/lib/flip/animation/controller-slot.test.ts b/src/lib/flip/controller-slot.test.ts similarity index 78% rename from src/lib/flip/animation/controller-slot.test.ts rename to src/lib/flip/controller-slot.test.ts index e531c4f..a15981a 100644 --- a/src/lib/flip/animation/controller-slot.test.ts +++ b/src/lib/flip/controller-slot.test.ts @@ -13,7 +13,7 @@ const { animateFlipMock } = vi.hoisted(() => ({ animateFlipMock: vi.fn() })); vi.mock('./animator', () => ({ animateFlip: animateFlipMock })); import { createControllerSlot } from './controller-slot'; -import type { FlipAnimateArgs, FlipRect, FlipRectPair } from '../types'; +import type { FlipAnimateArgs, FlipRect, FlipRectPair } from './types'; const rect = (x = 0, y = 0): FlipRect => ({ x, y, width: 10, height: 10 }); const rects: FlipRectPair = { from: rect(), to: rect(5, 5) }; @@ -123,3 +123,33 @@ describe('createControllerSlot()', () => { expect(ctrl.cancel).toHaveBeenCalledTimes(1); }); }); + +describe('interrupted runs', () => { + /** Resolve the duration the slot handed to the most recent animateFlip() call. */ + const lastDuration = () => { + const d = animateFlipMock.mock.calls.at(-1)![0].options.duration; + return typeof d === 'function' ? d(0, rects) : d; + }; + + it('charges elapsed time against each replacement until it hits the floor', () => { + animateFlipMock.mockImplementation(() => ({ cancel: vi.fn(), currentTime: 100 })); + const slot = createControllerSlot(); + + slot.run(args({ options: { duration: 400 } })); + expect(lastDuration()).toBe(400); + + slot.run(args({ options: { duration: 400 } })); + expect(lastDuration()).toBe(300); + + slot.run(args({ options: { duration: 400 } })); + expect(lastDuration()).toBe(200); + + slot.run(args({ options: { duration: 400 } })); + expect(lastDuration()).toBe(120); // floored, not 100 + + // A natural finish ends the burst — the next run gets the full duration. + lastWrappedOnEnd()({} as Element, { finished: true, rects }); + slot.run(args({ options: { duration: 400 } })); + expect(lastDuration()).toBe(400); + }); +}); diff --git a/src/lib/flip/controller-slot.ts b/src/lib/flip/controller-slot.ts new file mode 100644 index 0000000..03678eb --- /dev/null +++ b/src/lib/flip/controller-slot.ts @@ -0,0 +1,85 @@ +/** + * A single-slot holder for the in-flight FLIP controller. + * + * Both the `flip()` attachment and the switcher run at most one animation per + * element at a time: starting a new run cancels the previous one, and the slot + * auto-clears when an animation settles naturally. Centralizing that lifecycle + * keeps the attachment files declarative and removes the duplicated + * cancel / wrap-onEnd / null-on-finish boilerplate. + */ + +import type { AnimationController } from '../animate/types'; +import { animateFlip } from './animator'; +import { resolveDuration } from './options'; +import type { FlipAnimateArgs } from './types'; + +/** + * Floor for an interrupted run's shortened duration — below this a retarget + * reads as a snap rather than a movement. + */ +const MIN_INTERRUPT_DURATION = 120; + +interface ControllerSlot { + /** Cancel any in-flight controller, then start a new FLIP animation. */ + run: (args: FlipAnimateArgs) => void; + /** Whether a FLIP animation is currently in flight. */ + isActive: () => boolean; + /** Cancel the current controller and empty the slot (teardown). */ + cancel: () => void; +} + +/** Cancel a controller, tolerating animations already torn down during cleanup. */ +const cancelSafely = (controller: AnimationController | null): void => { + if (!controller) return; + try { + controller.cancel(); + } catch { + // noop — animation may already be torn down + } +}; + +export const createControllerSlot = (): ControllerSlot => { + let current: AnimationController | null = null; + /** Time already spent animating in the current burst of interruptions. */ + let carried = 0; + + return { + run: ({ options, ...rest }) => { + // A burst of triggers (drag, rapid shuffles) would otherwise restart the + // full duration on every retarget and never settle. Charge the time already + // spent against the replacement so the burst converges. + carried = current ? carried + (current.currentTime ?? 0) : 0; + cancelSafely(current); + const requested = options?.duration; + current = animateFlip({ + ...rest, + options: { + ...options, + duration: + carried > 0 + ? (_distance, rects) => + Math.max(MIN_INTERRUPT_DURATION, resolveDuration(requested, rects) - carried) + : requested, + // Clear the slot only on a natural finish — a cancel (finished: + // false) is always followed by a fresh assignment below, so guarding + // on `finished` prevents the outgoing run from nulling the new one. + onEnd: (el, info) => { + if (info.finished) { + current = null; + carried = 0; + } + options?.onEnd?.(el, info); + } + } + }); + // Nothing to animate (identity delta, reduced motion) ends the burst. + if (!current) carried = 0; + }, + isActive: () => current !== null, + cancel: () => { + cancelSafely(current); + current = null; + carried = 0; + } + }; +}; diff --git a/src/lib/flip/dynamic.svelte.test.ts b/src/lib/flip/dynamic.svelte.test.ts new file mode 100644 index 0000000..b17b928 --- /dev/null +++ b/src/lib/flip/dynamic.svelte.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { createDynamicAttrs } from './dynamic'; + +let mounted: Element[] = []; + +const mountBox = (className = ''): HTMLDivElement => { + const element = document.createElement('div'); + element.className = className; + document.body.appendChild(element); + mounted.push(element); + return element; +}; + +afterEach(() => { + for (const element of mounted) element.remove(); + mounted = []; +}); + +describe('createDynamicAttrs()', () => { + it('flattens ClassValue shapes and leaves markup classes alone', () => { + const element = mountBox('from-markup'); + const attrs = createDynamicAttrs(element); + + expect(attrs.write(['a', { b: true, c: false }], undefined)).toBe(true); + expect([...element.classList].sort()).toEqual(['a', 'b', 'from-markup']); + + expect(attrs.write(['b'], undefined)).toBe(true); + expect([...element.classList].sort()).toEqual(['b', 'from-markup']); + }); + + it('diffs style declarations without disturbing the animator’s properties', () => { + const element = mountBox(); + element.style.setProperty('--motion-x', '5px'); + const attrs = createDynamicAttrs(element); + + expect(attrs.write(undefined, 'color: red; --x: 1px')).toBe(true); + expect(element.style.getPropertyValue('color')).toBe('red'); + expect(element.style.getPropertyValue('--x')).toBe('1px'); + + expect(attrs.write(undefined, 'color: blue')).toBe(true); + expect(element.style.getPropertyValue('color')).toBe('blue'); + expect(element.style.getPropertyValue('--x')).toBe(''); + expect(element.style.getPropertyValue('--motion-x')).toBe('5px'); + }); + + it('preserves !important priority', () => { + const element = mountBox(); + const attrs = createDynamicAttrs(element); + + attrs.write(undefined, 'color: red !important'); + expect(element.style.getPropertyPriority('color')).toBe('important'); + }); + + it('reports no change when nothing moved, so callers can skip the reflow', () => { + const element = mountBox(); + const attrs = createDynamicAttrs(element); + + expect(attrs.write('a', 'color: red')).toBe(true); + expect(attrs.write('a', 'color: red')).toBe(false); + }); + + it('resets only what it applied', () => { + const element = mountBox('from-markup'); + element.style.setProperty('--motion-x', '5px'); + const attrs = createDynamicAttrs(element); + + attrs.write('a', 'color: red'); + attrs.reset(); + + expect([...element.classList]).toEqual(['from-markup']); + expect(element.style.getPropertyValue('color')).toBe(''); + expect(element.style.getPropertyValue('--motion-x')).toBe('5px'); + }); +}); diff --git a/src/lib/flip/dynamic.ts b/src/lib/flip/dynamic.ts new file mode 100644 index 0000000..2b9f659 --- /dev/null +++ b/src/lib/flip/dynamic.ts @@ -0,0 +1,111 @@ +/** + * Reactive `class` / `style` application for the FLIP attachment. + * + * The attachment owns these writes so the FLIP sequence is deterministic: + * measure `from` → write → measure `to` → animate, with no frame in between. + * + * Every write is a diff against what *we* previously applied — never a + * wholesale `className =` / `cssText =`. The animator writes `--motion-*` and + * `pointer-events` inline on the same element, and markup may carry its own + * classes; clobbering either would break an in-flight animation or the + * component's own styling. + * + * ponytail: a *dynamic* `class={…}` in markup still wins — Svelte's `set_class` + * assigns `className` wholesale when its expression changes, and we have no + * dependency on that. Use this option or a dynamic `class={…}`, not both. If + * that ever bites in practice, re-assert from a `MutationObserver` on the + * `class` attribute. + */ + +import type { ClassValue } from 'svelte/elements'; +import type { MotionElement } from '../shared/types'; + +export interface DynamicAttrs { + /** Write class/style; returns `true` when the DOM actually changed. */ + write: (classValue: ClassValue | undefined, styleValue: string | undefined) => boolean; + /** Remove everything we applied (teardown). */ + reset: () => void; +} + +/** Flatten Svelte's `ClassValue` shape into individual class tokens. */ +const flattenClass = (value: ClassValue | undefined): string[] => { + if (!value) return []; + if (typeof value === 'string') return value.split(/\s+/).filter(Boolean); + if (Array.isArray(value)) return value.flatMap((entry) => flattenClass(entry as ClassValue)); + return Object.keys(value).filter((key) => value[key]); +}; + +/** + * Parse a CSS declaration string with the platform's own parser — a detached + * element handles `!important`, comments, quoting, custom properties, and + * malformed input for free. Never assign `cssText` on the live element. + */ +let parser: MotionElement | null = null; + +const parseStyle = (value: string | undefined): Map => { + const declarations = new Map(); + if (!value) return declarations; + parser ??= document.createElement('div'); + parser.style.cssText = value; + const { style } = parser; + for (let i = 0; i < style.length; i++) { + const name = style[i]; + const priority = style.getPropertyPriority(name); + declarations.set(name, style.getPropertyValue(name) + (priority ? ` !${priority}` : '')); + } + return declarations; +}; + +/** Split a stored declaration back into the value/priority pair `setProperty` wants. */ +const splitPriority = (declaration: string): [value: string, priority: string] => + declaration.endsWith(' !important') + ? [declaration.slice(0, -' !important'.length), 'important'] + : [declaration, '']; + +export const createDynamicAttrs = (element: MotionElement): DynamicAttrs => { + let classes: string[] = []; + let styles = new Map(); + + return { + write(classValue, styleValue) { + const nextClasses = flattenClass(classValue); + const nextStyles = parseStyle(styleValue); + let changed = false; + + for (const token of classes) { + if (!nextClasses.includes(token)) { + element.classList.remove(token); + changed = true; + } + } + for (const token of nextClasses) { + if (!element.classList.contains(token)) { + element.classList.add(token); + changed = true; + } + } + classes = nextClasses; + + for (const name of styles.keys()) { + if (!nextStyles.has(name)) { + element.style.removeProperty(name); + changed = true; + } + } + for (const [name, declaration] of nextStyles) { + if (styles.get(name) === declaration) continue; + element.style.setProperty(name, ...splitPriority(declaration)); + changed = true; + } + styles = nextStyles; + + return changed; + }, + reset() { + for (const token of classes) element.classList.remove(token); + for (const name of styles.keys()) element.style.removeProperty(name); + classes = []; + styles = new Map(); + } + }; +}; diff --git a/src/lib/flip/geometry.test.ts b/src/lib/flip/geometry.test.ts index 14178fb..907233d 100644 --- a/src/lib/flip/geometry.test.ts +++ b/src/lib/flip/geometry.test.ts @@ -1,11 +1,17 @@ /** - * Tests for pure geometry primitives (rectsEqual, diagonal, computeDelta, isIdentityDelta). + * Tests for pure geometry primitives (rectsEqual, diagonal, delta math, isIdentityDelta). * No DOM access — runs in the `server` vitest project. */ import { describe, expect, it } from 'vitest'; -import { computeDelta, diagonal, isIdentityDelta, rectsEqual } from './geometry'; -import type { FlipRect } from './types'; +import { + buildFlipProps, + diagonal, + isIdentityDelta, + rectsEqual, + resolveFlipDelta +} from './geometry'; +import type { FlipRect } from './geometry'; const rect = (x: number, y: number, w: number, h: number): FlipRect => ({ x, @@ -70,10 +76,10 @@ describe('diagonal()', () => { }); }); -describe('computeDelta()', () => { +describe('resolveFlipDelta() — inverse delta math', () => { it('returns identity delta for identical rects', () => { const r = rect(0, 0, 100, 50); - const delta = computeDelta({ from: r, to: r }); + const delta = resolveFlipDelta(r, r, false); expect(delta.dx).toBe(0); expect(delta.dy).toBe(0); expect(delta.sx).toBe(1); @@ -83,7 +89,7 @@ describe('computeDelta()', () => { it('computes translation correctly', () => { const from = rect(10, 20, 100, 50); const to = rect(30, 50, 100, 50); - const delta = computeDelta({ from, to }); + const delta = resolveFlipDelta(from, to, false); expect(delta.dx).toBe(-20); // from.x - to.x expect(delta.dy).toBe(-30); // from.y - to.y expect(delta.sx).toBe(1); @@ -93,7 +99,7 @@ describe('computeDelta()', () => { it('computes scale correctly', () => { const from = rect(0, 0, 200, 100); const to = rect(0, 0, 100, 50); - const delta = computeDelta({ from, to }); + const delta = resolveFlipDelta(from, to, false); expect(delta.sx).toBe(2); // from.width / to.width expect(delta.sy).toBe(2); // from.height / to.height expect(delta.dx).toBe(0); @@ -103,7 +109,7 @@ describe('computeDelta()', () => { it('disabling translate zeroes dx/dy', () => { const from = rect(10, 20, 100, 50); const to = rect(30, 50, 100, 50); - const delta = computeDelta({ from, to }, { translate: false }); + const delta = resolveFlipDelta(from, to, false, { translate: false }); expect(delta.dx).toBe(0); expect(delta.dy).toBe(0); expect(delta.sx).toBe(1); @@ -113,7 +119,7 @@ describe('computeDelta()', () => { it('disabling scale sets sx/sy to 1', () => { const from = rect(0, 0, 200, 100); const to = rect(0, 0, 100, 50); - const delta = computeDelta({ from, to }, { scale: false }); + const delta = resolveFlipDelta(from, to, false, { scale: false }); expect(delta.sx).toBe(1); expect(delta.sy).toBe(1); }); @@ -121,7 +127,7 @@ describe('computeDelta()', () => { it('handles zero-size `to` rect without NaN (guards division by zero)', () => { const from = rect(0, 0, 100, 50); const to = rect(0, 0, 0, 0); - const delta = computeDelta({ from, to }); + const delta = resolveFlipDelta(from, to, false); expect(delta.sx).toBe(1); expect(delta.sy).toBe(1); }); @@ -148,3 +154,37 @@ describe('isIdentityDelta()', () => { expect(isIdentityDelta({ dx: 0, dy: 0, sx: 1, sy: 0.9 })).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// FLIP direction — the inverse / forward contract. The animator and every +// attachment route through `resolveFlipDelta`, so these guard the forward swap +// directly, not just the keyframe pairing. (`measure` needs DOM globals and is +// covered by the `client` browser project.) +// --------------------------------------------------------------------------- + +describe('resolveFlipDelta() direction', () => { + // Element physically at {x:0,w:100}; we want it to look like {x:200,w:50}. + const current = rect(0, 0, 100, 50); + const target = rect(200, 0, 50, 50); + + it('inverse FLIP returns the (from − to) transform placing the element at `from`', () => { + // from = old rect, to = current DOM position; no swap. + const delta = resolveFlipDelta(target, current, false); + expect(delta.dx).toBe(200); // target.x − current.x + expect(delta.sx).toBe(0.5); // target.w / current.w + const props = buildFlipProps(delta, false); + expect(props.flipX).toEqual(['200px', '0px']); // starts offset, ends at rest + }); + + it('forward FLIP swaps the pair so the element is driven toward the target', () => { + // from = current, to = target, forward = true → swap to (to − from). + const delta = resolveFlipDelta(current, target, true); + // Swapped: dx = target.x − current.x = +200 (NOT current.x − target.x = −200), + // sx = target.w / current.w = 0.5 (NOT 2). The swap is what makes the element + // move toward the target rather than the mirror-opposite direction. + expect(delta.dx).toBe(200); + expect(delta.sx).toBe(0.5); + const props = buildFlipProps(delta, true); + expect(props.flipX).toEqual(['0px', '200px']); // starts at rest, ends at target + }); +}); diff --git a/src/lib/flip/geometry.ts b/src/lib/flip/geometry.ts index 7db283d..5275e5b 100644 --- a/src/lib/flip/geometry.ts +++ b/src/lib/flip/geometry.ts @@ -1,20 +1,98 @@ /** - * Pure geometry primitives for FLIP — no DOM mutation, no side effects. + * FLIP geometry — rect capture and delta math. The single source of truth for + * which way a FLIP moves; the animator and every attachment route through it, + * so no two call sites can disagree on direction. * - * This is a thin flip-layer surface: `rectsEqual` and `diagonal` live here, - * while delta math (`computeDelta`, `isIdentityDelta`) and the transform-aware - * rect reader (`measure`, aliased to `captureRect`) are owned by the lower - * `animate()` layer and re-exported so flip consumers keep a single import. + * Only `measure` / `measureVisual` touch the DOM (read-only); everything else + * is pure. */ -import { captureRect, captureVisualRect } from '$lib/animate/flip'; -import type { FlipRect } from './types'; +import { measureWithoutAncestorTransforms } from '../animate/properties/properties'; +import type { AnimateProps } from '../animate/types'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Axis-aligned bounding rectangle in viewport coordinates. */ +export interface FlipRect { + x: number; + y: number; + width: number; + height: number; +} + +/** A pair of rects describing a layout change (`from` → `to`). */ +export interface FlipRectPair { + from: FlipRect; + to: FlipRect; +} + +export interface FlipDelta { + /** Horizontal translate component (`from.x - to.x`). */ + dx: number; + /** Vertical translate component (`from.y - to.y`). */ + dy: number; + /** Horizontal scale component (`from.width / to.width`). */ + sx: number; + /** Vertical scale component (`from.height / to.height`). */ + sy: number; +} + +interface DeltaOptions { + translate?: boolean; + scale?: boolean; +} + +export const FLIP_DEFAULT_DURATION = 280; + +// --------------------------------------------------------------------------- +// Measurement +// --------------------------------------------------------------------------- /** Read the element's layout rect, suppressing any in-flight motion transforms. */ -export const measure = captureRect; +export const measure = (element: Element): FlipRect => { + const { left: x, top: y, width, height } = measureWithoutAncestorTransforms(element); + return { x, y, width, height }; +}; -/** Read the element's live visual rect, keeping its own in-flight transform. */ -export const measureVisual = captureVisualRect; +/** + * Read the element's current *visual* rect — its own in-flight motion + * transform included, ancestor transforms still suppressed. Use this as the + * `from` rect when interrupting an in-flight FLIP so the replacement animation + * starts exactly where the element is on-screen, instead of snapping to its + * resting layout box first. + */ +export const measureVisual = (element: Element): FlipRect => { + const { + left: x, + top: y, + width, + height + } = measureWithoutAncestorTransforms(element, { suppressSelf: false }); + return { x, y, width, height }; +}; + +/** + * Re-express a live visual rect in the *previous* layout frame. + * + * An in-flight transform is an offset from the element's resting box. Once the + * layout changes, that same offset is measured against the *new* box, so the + * visual rect is no longer the position the element occupied a moment ago. + * Carrying the offset back onto `prev` recovers it, which is what an + * interrupting FLIP must animate from — otherwise the offset is added to the + * new slot and the element visibly shoots out of place. + */ +export const carryVisualOffset = ( + prev: FlipRect, + resting: FlipRect, + visual: FlipRect +): FlipRect => ({ + x: prev.x + (visual.x - resting.x), + y: prev.y + (visual.y - resting.y), + width: resting.width > 0 ? prev.width * (visual.width / resting.width) : prev.width, + height: resting.height > 0 ? prev.height * (visual.height / resting.height) : prev.height +}); /** Approximate equality so sub-pixel jitter doesn't trigger reflows. */ export const rectsEqual = (a: FlipRect, b: FlipRect, epsilon = 0.5): boolean => @@ -27,7 +105,60 @@ export const rectsEqual = (a: FlipRect, b: FlipRect, epsilon = 0.5): boolean => export const diagonal = (a: FlipRect, b: FlipRect): number => Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2); -// Delta math is owned by the lower `animate` layer — re-export so flip-layer -// consumers keep a single `./geometry` import surface. -export { computeDelta, isIdentityDelta, resolveFlipDelta } from '$lib/animate/flip'; -export type { FlipDelta, DeltaOptions } from '$lib/animate/flip'; +// --------------------------------------------------------------------------- +// Delta math +// --------------------------------------------------------------------------- + +/** + * Compute the inverted transform that places the element back at `from`. + * Components disabled in `opts` resolve to their identity. + */ +const computeDelta = ( + { from, to }: FlipRectPair, + { translate = true, scale = true }: DeltaOptions = {} +): FlipDelta => ({ + dx: translate ? from.x - to.x : 0, + dy: translate ? from.y - to.y : 0, + sx: scale && to.width > 0 ? from.width / to.width : 1, + sy: scale && to.height > 0 ? from.height / to.height : 1 +}); + +/** True when the delta would produce no visible movement. */ +export const isIdentityDelta = (delta: FlipDelta): boolean => + delta.dx === 0 && delta.dy === 0 && delta.sx === 1 && delta.sy === 1; + +/** + * Resolve the FLIP delta for a layout change, honoring direction. + * + * Inverse (`forward: false`): DOM is at `to`; the delta is the inverse + * transform `(from − to)` that places the element back at `from`. + * Forward (`forward: true`): DOM is at `from`; the rect pair is swapped so the + * delta becomes `(to − from)`, driving the element visually toward `to`. + */ +export const resolveFlipDelta = ( + from: FlipRect, + to: FlipRect, + forward: boolean, + opts?: DeltaOptions +): FlipDelta => computeDelta(forward ? { from: to, to: from } : { from, to }, opts); + +/** + * Build the `animate()` props object for a FLIP delta. + * + * Inverse (default): DOM is at `to`; apply `[Δ→0]` to start visually at `from`. + * Forward: DOM is at `from`; apply `[0→Δ]` to drive visually toward `to`. + */ +export const buildFlipProps = ({ dx, dy, sx, sy }: FlipDelta, forward: boolean): AnimateProps => { + // `pair` orders the [from, to] keyframe by direction; `translate`/`scale` + // capture each component's unit and identity so they live in one place. + const pair = (delta: string, identity: string): [string, string] => + forward ? [identity, delta] : [delta, identity]; + const translate = (v: number): [string, string] => pair(`${v}px`, '0px'); + const scale = (v: number): [string, string] => pair(`${v}`, '1'); + return { + flipX: translate(dx), + flipY: translate(dy), + flipScaleX: scale(sx), + flipScaleY: scale(sy) + }; +}; diff --git a/src/lib/flip/index.ts b/src/lib/flip/index.ts index 4300a94..56d4483 100644 --- a/src/lib/flip/index.ts +++ b/src/lib/flip/index.ts @@ -1,18 +1,19 @@ /** * Public entry point for the FLIP module. * - * - Low-level primitives (`animateFlip`, `createFlipScope`, `createLayoutBridge`, …) - * are re-exported for consumers that need direct access. - * - High-level helpers (`flip`, `snapshotRect`, `flipFrom`) live here so - * callers import from a single path instead of from a parallel barrel. + * Everything a consumer needs — `flip`, `snapshotRect`, `flipFrom`, + * `createFlipScope`, `anchoredFlip` — lives here. Observers, schedulers, + * attribute application, and the layout bridge are internals and deliberately + * not exported. */ import type { Attachment } from 'svelte/attachments'; -import type { AnimationController } from '$lib/animate/types'; -import { animateFlip } from './animation/animator'; -import { createFlipAttachment } from './integration/attachment.svelte'; +import type { AnimationController } from '../animate/types'; +import { animateFlip } from './animator'; +import { createFlipAttachment } from './attachment.svelte'; +import { createLayoutBridge } from './bridge'; import { measure } from './geometry'; -import type { FlipOptions, FlipOptionsInput, FlipRect, MotionElement } from './types'; +import type { FlipOptions, FlipOptionsInput, FlipRect, FlipScope, MotionElement } from './types'; // --------------------------------------------------------------------------- // High-level API @@ -26,8 +27,9 @@ import type { FlipOptions, FlipOptionsInput, FlipRect, MotionElement } from './t * ```svelte *
auto-tracks layout shifts
*
...
- * - *
{ void open; } })}>...
+ * + *
({ 'is-open': open }) })}>...
+ *
(open ? 'height: 320px' : 'height: 64px') })}>...
* ``` */ export const flip = (input?: FlipOptionsInput): Attachment => @@ -43,15 +45,7 @@ export const flipFrom = ( options: FlipOptions = {} ): AnimationController | null => animateFlip({ element, from, to: measure(element), options }); -/** - * Animate an element from its current position to a captured rect. - * - * This is a *forward* FLIP: the DOM stays at its current position while the - * element is driven visually toward `to` via keyframes `[0 → Δ]`. Use it for - * collapse / exit animations where the DOM has not yet been moved (e.g. the - * element is about to be removed). Standard inverse-FLIP (`flipFrom`) requires - * the DOM to already be at the target position. - */ +/** Animate an element from its current position to a captured rect. */ export const flipTo = ( element: MotionElement, to: FlipRect, @@ -59,23 +53,42 @@ export const flipTo = ( ): AnimationController | null => animateFlip({ element, from: measure(element), to, options, forward: true }); +/** + * Create an isolated FLIP scope with a shared-layout registry. + * Use `layoutId` on attachments from the same scope for cross-component + * shared-element transitions. No global state involved. + * + * @example + * ```ts + * const { flip } = createFlipScope(); + * ``` + * ```svelte + *
...
+ * ``` + */ +export const createFlipScope = (): FlipScope => { + const bridge = createLayoutBridge(); + return { + flip: (input?: FlipOptionsInput) => createFlipAttachment(input, bridge) + }; +}; + +export { anchoredFlip } from './anchored'; +export type { + AnchoredFlipNodeFunction, + AnchoredFlipOptions, + AnchoredFlipReference, + AnchoredFlipReferenceGetter +} from './anchored'; + // --------------------------------------------------------------------------- // Low-level re-exports // --------------------------------------------------------------------------- -export { createFlipSwitcher } from './integration/switcher.svelte'; -export type { FlipSwitcher, FlipSwitchRole } from './integration/switcher.svelte'; +export { createFlipSwitcher } from './switcher.svelte'; +export type { FlipSwitcher, FlipSwitchRole } from './switcher.svelte'; -export { animateFlip } from './animation/animator'; -export { createFlipAttachment } from './integration/attachment.svelte'; -export { createFlipScope } from './integration/scope'; -export { createLayoutBridge } from './integration/bridge'; -export { computeDelta, diagonal, isIdentityDelta, measure, rectsEqual } from './geometry'; -export { createObserverManager } from './tracking/observer-manager'; -export type { ObserverManager } from './tracking/observer-manager'; -export { createReflowScheduler } from './tracking/scheduler'; +export { animateFlip } from './animator'; +export { measure } from './geometry'; -export type { FlipDelta, DeltaOptions } from './geometry'; -export type { LayoutBridgeHandle } from './integration/bridge'; -export type { ReflowScheduler } from './tracking/scheduler'; export type * from './types'; diff --git a/src/lib/flip/integration/README.md b/src/lib/flip/integration/README.md deleted file mode 100644 index 867aca4..0000000 --- a/src/lib/flip/integration/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# flip/integration - -The Svelte 5 wiring for [`flip`](../README.md) — the `{@attach}` attachment, the shared-layout registry, and the higher-level scope and switcher factories. - -| File | Responsibility | -| --- | --- | -| `attachment.svelte.ts` | `createFlipAttachment(input, bridge)` — the core attachment. Wires the animator to lifecycle events, detects reflows via `$effect`, optionally syncs with an `ObserverManager` for auto-tracking, and reads/writes layouts to the bridge for shared-element transitions. | -| `bridge.ts` | `createLayoutBridge(ttlMs?)` — an in-memory registry storing each `layoutId`'s last-known rect with a configurable TTL (default 250 ms), enabling handoff between an unmounting and a mounting element. | -| `scope.ts` | `createFlipScope(opts?)` — returns `{ flip, clear }`, wrapping a bridge + attachment into a self-contained namespace for cross-component shared transitions. | -| `switcher.svelte.ts` | `createFlipSwitcher(resolver, options?)` — animates between two elements. Uses `$effect.pre` to snapshot pre-update rects, then plays FLIP on the newly-active element when the role changes. Returns `{ source, target }` attachments. | - -These rely on Svelte 5 runes (`$effect`, `$effect.pre`, `untrack`) and the `{@attach}` directive — hence the `.svelte.ts` extensions where rune syntax is used. diff --git a/src/lib/flip/integration/bridge.ts b/src/lib/flip/integration/bridge.ts deleted file mode 100644 index 9a2a9ac..0000000 --- a/src/lib/flip/integration/bridge.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Layout bridge for shared-element transitions. - * - * Stores the last-known rect for a `layoutId` with a TTL so a sibling - * mounting just after another unmounts can read the position without - * incurring a global registry. - */ - -import type { FlipRect } from '../types'; - -interface LayoutRecord { - rect: FlipRect; - timestamp: number; -} - -export interface LayoutBridge { - readLayout: (id: string) => FlipRect | null; - writeLayout: (id: string, rect: FlipRect) => void; -} - -export interface LayoutBridgeHandle { - bridge: LayoutBridge; - /** Drop every stored rect — useful between routes or in tests. */ - clear: () => void; -} - -/** Default lifetime of an unread layout record, in ms. */ -const DEFAULT_LAYOUT_TTL_MS = 250; - -const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()); - -/** - * Create an in-memory layout registry exposing the {@link LayoutBridge} - * interface plus a `clear()` escape hatch. - * - * @param ttlMs How long an unread record stays valid. Defaults to - * {@link DEFAULT_LAYOUT_TTL_MS}. - */ -export const createLayoutBridge = (ttlMs?: number): LayoutBridgeHandle => { - const ttl = ttlMs ?? DEFAULT_LAYOUT_TTL_MS; - const registry = new Map(); - - const bridge: LayoutBridge = { - writeLayout: (id, rect) => { - registry.set(id, { rect, timestamp: now() }); - }, - readLayout: (id) => { - const entry = registry.get(id); - if (!entry) return null; - if (now() - entry.timestamp > ttl) { - registry.delete(id); - return null; - } - return entry.rect; - } - }; - - return { - bridge, - clear: () => registry.clear() - }; -}; diff --git a/src/lib/flip/integration/scope.test.ts b/src/lib/flip/integration/scope.test.ts deleted file mode 100644 index 4a8571d..0000000 --- a/src/lib/flip/integration/scope.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Tests for createFlipScope(). - * Verifies the returned API surface and the shared-layout bridge integration. - * Runs in the `server` vitest project. - */ - -import { describe, expect, it } from 'vitest'; -import { createFlipScope } from './scope'; - -describe('createFlipScope()', () => { - it('returns an object with flip and clear', () => { - const scope = createFlipScope(); - expect(typeof scope.flip).toBe('function'); - expect(typeof scope.clear).toBe('function'); - }); - - it('flip() returns a function (Svelte attachment)', () => { - const { flip } = createFlipScope(); - const attachment = flip(); - expect(typeof attachment).toBe('function'); - }); - - it('flip() accepts options', () => { - const { flip } = createFlipScope(); - expect(() => flip({ duration: 300 })).not.toThrow(); - }); - - it('flip() accepts no options', () => { - const { flip } = createFlipScope(); - expect(() => flip()).not.toThrow(); - }); - - it('clear() does not throw on empty scope', () => { - const { clear } = createFlipScope(); - expect(() => clear()).not.toThrow(); - }); - - it('accepts custom layoutTtlMs option', () => { - expect(() => createFlipScope({ layoutTtlMs: 500 })).not.toThrow(); - }); - - it('each call returns an independent scope', () => { - const a = createFlipScope(); - const b = createFlipScope(); - // Scopes use independent bridges; they don't share state - expect(a).not.toBe(b); - }); - - it('clear() is independent between scopes (no shared state)', () => { - // Verify scopes are truly isolated — clearing one does not affect the other. - // We test this indirectly: both scopes' clear() can run without throwing. - const a = createFlipScope(); - const b = createFlipScope(); - a.clear(); - expect(() => b.clear()).not.toThrow(); - }); -}); diff --git a/src/lib/flip/integration/scope.ts b/src/lib/flip/integration/scope.ts deleted file mode 100644 index 41cbddf..0000000 --- a/src/lib/flip/integration/scope.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Public scope factory. - * - * Wraps the registry from `bridge.ts` together with the attachment factory - * so callers get a self-contained shared-layout namespace. - */ - -import { createFlipAttachment } from './attachment.svelte'; -import { createLayoutBridge } from './bridge'; -import type { CreateFlipScopeOptions, FlipOptionsInput, FlipScope } from '../types'; - -/** - * Create an isolated FLIP scope with a shared-layout registry. - * Use `layoutId` on attachments from the same scope for cross-component - * shared-element transitions. No global state involved. - * - * @example - * ```ts - * const { flip } = createFlipScope(); - * ``` - * ```svelte - *
...
- * ``` - */ -export const createFlipScope = (opts: CreateFlipScopeOptions = {}): FlipScope => { - const { bridge, clear } = createLayoutBridge(opts.layoutTtlMs); - - return { - flip: (input?: FlipOptionsInput) => createFlipAttachment(input, bridge), - clear - }; -}; diff --git a/src/lib/flip/tracking/observers.test.ts b/src/lib/flip/observers.test.ts similarity index 63% rename from src/lib/flip/tracking/observers.test.ts rename to src/lib/flip/observers.test.ts index e612fd8..226f99e 100644 --- a/src/lib/flip/tracking/observers.test.ts +++ b/src/lib/flip/observers.test.ts @@ -1,37 +1,39 @@ /** - * Tests for createLayoutObservers(). + * Tests for createObserverManager(). * In non-DOM environments (node), ResizeObserver and MutationObserver are * undefined — connect() is a safe no-op, and disconnect() is idempotent. * Runs in the `server` vitest project. */ import { describe, expect, it, vi } from 'vitest'; -import { createLayoutObservers } from './observers'; +import { createObserverManager } from './observers'; const fakeEl = { parentElement: null } as unknown as Element; -describe('createLayoutObservers() — non-DOM environment', () => { +const noop = (): void => {}; + +describe('createObserverManager() — non-DOM environment', () => { it('returns an object with connect and disconnect', () => { - const obs = createLayoutObservers({ element: fakeEl, onChange: () => {} }); + const obs = createObserverManager(); expect(typeof obs.connect).toBe('function'); expect(typeof obs.disconnect).toBe('function'); }); it('connect() does not throw when observers are unavailable', () => { - const obs = createLayoutObservers({ element: fakeEl, onChange: () => {} }); - expect(() => obs.connect()).not.toThrow(); + const obs = createObserverManager(); + expect(() => obs.connect(fakeEl, noop)).not.toThrow(); }); it('disconnect() does not throw before connect()', () => { - const obs = createLayoutObservers({ element: fakeEl, onChange: () => {} }); + const obs = createObserverManager(); expect(() => obs.disconnect()).not.toThrow(); }); it('disconnect() is idempotent', () => { - const obs = createLayoutObservers({ element: fakeEl, onChange: () => {} }); - obs.connect(); + const obs = createObserverManager(); + obs.connect(fakeEl, noop); expect(() => { obs.disconnect(); obs.disconnect(); @@ -39,14 +41,14 @@ describe('createLayoutObservers() — non-DOM environment', () => { }); it('re-connect() after disconnect() does not throw', () => { - const obs = createLayoutObservers({ element: fakeEl, onChange: () => {} }); - obs.connect(); + const obs = createObserverManager(); + obs.connect(fakeEl, noop); obs.disconnect(); - expect(() => obs.connect()).not.toThrow(); + expect(() => obs.connect(fakeEl, noop)).not.toThrow(); }); }); -describe('createLayoutObservers() — with mocked observers', () => { +describe('createObserverManager() — with mocked observers', () => { it('calls ResizeObserver.observe when available', () => { const observe = vi.fn(); const disconnect = vi.fn(); @@ -61,9 +63,8 @@ describe('createLayoutObservers() — with mocked observers', () => { const origRO = (globalThis as Record).ResizeObserver; (globalThis as Record).ResizeObserver = MockResizeObserver; - const onChange = vi.fn(); - const obs = createLayoutObservers({ element: fakeEl, onChange }); - obs.connect(); + const obs = createObserverManager(); + obs.connect(fakeEl, vi.fn()); expect(constructorCalls).toHaveLength(1); expect(observe).toHaveBeenCalledWith(fakeEl); @@ -81,8 +82,8 @@ describe('createLayoutObservers() — with mocked observers', () => { const origRO = (globalThis as Record).ResizeObserver; (globalThis as Record).ResizeObserver = MockResizeObserver; - const obs = createLayoutObservers({ element: fakeEl, onChange: vi.fn() }); - obs.connect(); + const obs = createObserverManager(); + obs.connect(fakeEl, vi.fn()); obs.disconnect(); expect(roDisconnect).toHaveBeenCalledOnce(); @@ -90,6 +91,28 @@ describe('createLayoutObservers() — with mocked observers', () => { (globalThis as Record).ResizeObserver = origRO; }); + it('re-connecting drops the previous element’s observers', () => { + const roDisconnect = vi.fn(); + const observe = vi.fn(); + class MockResizeObserver { + observe = observe; + disconnect = roDisconnect; + constructor() {} + } + const origRO = (globalThis as Record).ResizeObserver; + (globalThis as Record).ResizeObserver = MockResizeObserver; + + const other = { parentElement: null } as unknown as Element; + const obs = createObserverManager(); + obs.connect(fakeEl, vi.fn()); + obs.connect(other, vi.fn()); + + expect(roDisconnect).toHaveBeenCalledOnce(); + expect(observe).toHaveBeenNthCalledWith(2, other); + + (globalThis as Record).ResizeObserver = origRO; + }); + it('calls MutationObserver.observe on the parent element when available', () => { const moObserve = vi.fn(); const moDisconnect = vi.fn(); @@ -104,8 +127,8 @@ describe('createLayoutObservers() — with mocked observers', () => { const parent = { parentElement: null } as unknown as Element; const elWithParent = { parentElement: parent } as unknown as Element; - const obs = createLayoutObservers({ element: elWithParent, onChange: vi.fn() }); - obs.connect(); + const obs = createObserverManager(); + obs.connect(elWithParent, vi.fn()); expect(moObserve).toHaveBeenCalledWith(parent, { childList: true, diff --git a/src/lib/flip/observers.ts b/src/lib/flip/observers.ts new file mode 100644 index 0000000..04972af --- /dev/null +++ b/src/lib/flip/observers.ts @@ -0,0 +1,43 @@ +/** + * Auto-tracking observers. + * + * Wraps a `ResizeObserver` on the element plus a `MutationObserver` on its + * parent (sibling reorder/insertion/removal) behind one connect/disconnect + * pair. `connect()` is idempotent and rewires to the element's current parent, + * so re-connecting after a reparent (or with a different element) is safe. + */ + +export interface ObserverManager { + connect: (element: Element, onChange: () => void) => void; + disconnect: () => void; +} + +export const createObserverManager = (): ObserverManager => { + let resizeObserver: ResizeObserver | null = null; + let mutationObserver: MutationObserver | null = null; + + const disconnect = (): void => { + resizeObserver?.disconnect(); + resizeObserver = null; + mutationObserver?.disconnect(); + mutationObserver = null; + }; + + return { + disconnect, + connect(element, onChange) { + disconnect(); + + if (typeof ResizeObserver === 'function') { + resizeObserver = new ResizeObserver(onChange); + resizeObserver.observe(element); + } + + const parent = element.parentElement; + if (parent && typeof MutationObserver === 'function') { + mutationObserver = new MutationObserver(onChange); + mutationObserver.observe(parent, { childList: true, subtree: false }); + } + } + }; +}; diff --git a/src/lib/flip/options.test.ts b/src/lib/flip/options.test.ts index 7785051..c62ec3f 100644 --- a/src/lib/flip/options.test.ts +++ b/src/lib/flip/options.test.ts @@ -7,13 +7,13 @@ import { describe, expect, it } from 'vitest'; import { easeOut } from '$lib/easing'; import { DEFAULT_DELAY, - DEFAULT_DURATION, - DEFAULT_EASING, readOptions, resolveDuration, resolveEasing, resolveOpacity } from './options'; +import { FLIP_DEFAULT_DURATION } from './geometry'; +import { DEFAULT_EASING } from '../animate/keyframes/easing-utils'; import type { FlipRectPair } from './types'; const pairAt = (dx = 0, dy = 0): FlipRectPair => ({ @@ -22,8 +22,8 @@ const pairAt = (dx = 0, dy = 0): FlipRectPair => ({ }); describe('constants', () => { - it('DEFAULT_DURATION is a positive number', () => { - expect(DEFAULT_DURATION).toBeGreaterThan(0); + it('FLIP_DEFAULT_DURATION is a positive number', () => { + expect(FLIP_DEFAULT_DURATION).toBeGreaterThan(0); }); it('DEFAULT_DELAY is 0', () => { @@ -63,8 +63,8 @@ describe('readOptions()', () => { describe('resolveDuration()', () => { const rects = pairAt(); - it('returns DEFAULT_DURATION when undefined', () => { - expect(resolveDuration(undefined, rects)).toBe(DEFAULT_DURATION); + it('returns FLIP_DEFAULT_DURATION when undefined', () => { + expect(resolveDuration(undefined, rects)).toBe(FLIP_DEFAULT_DURATION); }); it('returns the literal value for a number', () => { diff --git a/src/lib/flip/options.ts b/src/lib/flip/options.ts index d13ca56..109bea4 100644 --- a/src/lib/flip/options.ts +++ b/src/lib/flip/options.ts @@ -3,12 +3,10 @@ * animator. All helpers are pure — no DOM access, no side effects. */ -import { CSS_EASINGS } from '$lib/easing'; -import type { EasingFn } from '$lib/shared/types'; -import { DEFAULT_FLIP_DURATION as DEFAULT_DURATION } from '$lib/animate/flip'; -import { DEFAULT_EASING } from '$lib/animate/keyframes/easing-utils'; -import { atLeast0 } from '$lib/shared/math'; -import { diagonal } from './geometry'; +import { CSS_EASINGS } from '../easing'; +import type { EasingFn } from '../shared/types'; +import { DEFAULT_EASING } from '../animate/keyframes/easing-utils'; +import { FLIP_DEFAULT_DURATION, diagonal } from './geometry'; import type { FlipDuration, FlipEasing, @@ -18,7 +16,6 @@ import type { FlipRectPair } from './types'; -export { DEFAULT_DURATION, DEFAULT_EASING }; export const DEFAULT_DELAY = 0; /** Unwrap an options thunk; thunks let callers track reactive state. */ @@ -32,9 +29,9 @@ export const readOptions = (input: FlipOptionsInput): FlipOptions => { * function of the rect-to-rect diagonal distance. */ export const resolveDuration = (d: FlipDuration | undefined, rects: FlipRectPair): number => { - if (d == null) return DEFAULT_DURATION; + if (d == null) return FLIP_DEFAULT_DURATION; const raw = typeof d === 'function' ? d(diagonal(rects.from, rects.to), rects) : d; - return atLeast0(raw); + return Math.max(0, raw); }; /** Normalize the `opacity` shorthand into a fully-populated config or `null`. */ diff --git a/src/lib/flip/integration/switcher.svelte.ts b/src/lib/flip/switcher.svelte.ts similarity index 93% rename from src/lib/flip/integration/switcher.svelte.ts rename to src/lib/flip/switcher.svelte.ts index 703872d..45400cd 100644 --- a/src/lib/flip/integration/switcher.svelte.ts +++ b/src/lib/flip/switcher.svelte.ts @@ -20,10 +20,10 @@ import { untrack } from 'svelte'; import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '$lib/shared/browser'; -import { createControllerSlot } from '../animation/controller-slot'; -import { measure } from '../geometry'; -import type { FlipOptions, FlipRect, MotionElement } from '../types'; +import { isBrowser } from '../shared/browser'; +import { createControllerSlot } from './controller-slot'; +import { measure } from './geometry'; +import type { FlipOptions, FlipRect, MotionElement } from './types'; // --------------------------------------------------------------------------- // Public types diff --git a/src/lib/flip/tracking/README.md b/src/lib/flip/tracking/README.md deleted file mode 100644 index 6c9859c..0000000 --- a/src/lib/flip/tracking/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# flip/tracking - -Layout observation and frame scheduling for [`flip`](../README.md). This is what makes `flip()` auto-track layout shifts without the caller manually triggering a remeasure. - -| File | Responsibility | -| --- | --- | -| `observers.ts` | `createLayoutObservers({ element, onChange })` — combines a `ResizeObserver` (on the element) and a `MutationObserver` (on its parent) to detect size and reflow changes. `connect()` is idempotent and handles reparenting. | -| `observer-manager.ts` | `createObserverManager()` — wraps `LayoutObservers` behind a `{ connect, disconnect }` interface so the attachment can reconnect to a new element without leaking observers. | -| `scheduler.ts` | `createReflowScheduler(task)` — a RAF-batched scheduler. Coalesces multiple `schedule()` calls in a frame into a single task on the next animation frame; degrades to synchronous execution outside the DOM. | - -The attachment in [`../integration`](../integration/README.md) drives these: observers report a change → the scheduler batches it → the animator replays the FLIP. diff --git a/src/lib/flip/tracking/observer-manager.test.ts b/src/lib/flip/tracking/observer-manager.test.ts deleted file mode 100644 index 4d4a1a3..0000000 --- a/src/lib/flip/tracking/observer-manager.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Tests for createObserverManager(). - * Delegates to createLayoutObservers internally; tested at the manager level. - * Runs in the `server` vitest project. - */ - -import { describe, expect, it, vi } from 'vitest'; -import { createObserverManager } from './observer-manager'; - -const fakeEl = { parentElement: null } as unknown as Element; - -describe('createObserverManager()', () => { - it('returns an object with connect and disconnect', () => { - const mgr = createObserverManager(); - expect(typeof mgr.connect).toBe('function'); - expect(typeof mgr.disconnect).toBe('function'); - }); - - it('connect() does not throw', () => { - const mgr = createObserverManager(); - expect(() => mgr.connect(fakeEl, () => {})).not.toThrow(); - }); - - it('disconnect() does not throw before connect()', () => { - const mgr = createObserverManager(); - expect(() => mgr.disconnect()).not.toThrow(); - }); - - it('disconnect() does not throw after connect()', () => { - const mgr = createObserverManager(); - mgr.connect(fakeEl, () => {}); - expect(() => mgr.disconnect()).not.toThrow(); - }); - - it('calling connect() twice disconnects the previous observers', () => { - const mgr = createObserverManager(); - const el2 = { parentElement: null } as unknown as Element; - mgr.connect(fakeEl, () => {}); - // Second connect should not throw — previous inner is disconnected first - expect(() => mgr.connect(el2, vi.fn())).not.toThrow(); - }); - - it('disconnect() is idempotent', () => { - const mgr = createObserverManager(); - mgr.connect(fakeEl, () => {}); - expect(() => { - mgr.disconnect(); - mgr.disconnect(); - }).not.toThrow(); - }); -}); diff --git a/src/lib/flip/tracking/observer-manager.ts b/src/lib/flip/tracking/observer-manager.ts deleted file mode 100644 index f8056c1..0000000 --- a/src/lib/flip/tracking/observer-manager.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createLayoutObservers, type LayoutObservers } from './observers'; - -export interface ObserverManager { - connect: (element: Element, onChange: () => void) => void; - disconnect: () => void; -} - -export const createObserverManager = (): ObserverManager => { - let inner: LayoutObservers | null = null; - - return { - connect(element, onChange) { - inner?.disconnect(); - inner = createLayoutObservers({ element, onChange }); - inner.connect(); - }, - disconnect() { - inner?.disconnect(); - inner = null; - } - }; -}; diff --git a/src/lib/flip/tracking/observers.ts b/src/lib/flip/tracking/observers.ts deleted file mode 100644 index 8709a1c..0000000 --- a/src/lib/flip/tracking/observers.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Auto-tracking observers. - * - * Wraps a `ResizeObserver` on the element plus a `MutationObserver` on its - * parent (sibling reorder/insertion/removal) into a single connect/disconnect - * pair. Connection is idempotent, and re-`connect()`ing rewires to the - * current parent (handy when the element is reparented). - */ - -export interface LayoutObservers { - connect: () => void; - disconnect: () => void; -} - -interface LayoutObserverArgs { - element: Element; - onChange: () => void; -} - -export const createLayoutObservers = ({ - element, - onChange -}: LayoutObserverArgs): LayoutObservers => { - let resizeObserver: ResizeObserver | null = null; - let mutationObserver: MutationObserver | null = null; - - const disconnect = (): void => { - resizeObserver?.disconnect(); - resizeObserver = null; - mutationObserver?.disconnect(); - mutationObserver = null; - }; - - const connect = (): void => { - disconnect(); - - if (typeof ResizeObserver === 'function') { - resizeObserver = new ResizeObserver(onChange); - resizeObserver.observe(element); - } - - const parent = element.parentElement; - if (parent && typeof MutationObserver === 'function') { - mutationObserver = new MutationObserver(onChange); - mutationObserver.observe(parent, { childList: true, subtree: false }); - } - }; - - return { connect, disconnect }; -}; diff --git a/src/lib/flip/tracking/scheduler.test.ts b/src/lib/flip/tracking/scheduler.test.ts deleted file mode 100644 index 8f90c72..0000000 --- a/src/lib/flip/tracking/scheduler.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Tests for the RAF-batched reflow scheduler. - * In non-DOM environments (node), the scheduler falls back to synchronous - * execution — all logic is exercised without a real RAF. - * Runs in the `server` vitest project. - */ - -import { describe, expect, it, vi } from 'vitest'; -import { createReflowScheduler } from './scheduler'; - -describe('createReflowScheduler() — non-DOM (synchronous fallback)', () => { - it('returns an object with schedule and cancel', () => { - const s = createReflowScheduler(() => {}); - expect(typeof s.schedule).toBe('function'); - expect(typeof s.cancel).toBe('function'); - }); - - it('task is called synchronously when schedule() is invoked (no RAF)', () => { - let called = false; - const s = createReflowScheduler(() => { - called = true; - }); - s.schedule(); - expect(called).toBe(true); - }); - - it('task is called exactly once per schedule() when RAF is absent', () => { - let count = 0; - const s = createReflowScheduler(() => count++); - s.schedule(); - s.schedule(); - // Synchronous fallback runs immediately on each call - // (no coalescing needed since there is no async queue) - expect(count).toBeGreaterThanOrEqual(1); - }); - - it('cancel() does not throw even with no pending task', () => { - const s = createReflowScheduler(() => {}); - expect(() => s.cancel()).not.toThrow(); - }); - - it('cancel() after schedule() does not throw', () => { - const s = createReflowScheduler(() => {}); - s.schedule(); - expect(() => s.cancel()).not.toThrow(); - }); - - it('task receives no arguments', () => { - const task = vi.fn(); - const s = createReflowScheduler(task); - s.schedule(); - expect(task).toHaveBeenCalledWith(); - }); -}); - -describe('createReflowScheduler() — with mocked RAF', () => { - it('coalesces multiple schedule() calls into one task invocation', () => { - vi.useFakeTimers(); - let rafCallback: FrameRequestCallback | null = null; - const origRaf = globalThis.requestAnimationFrame; - const origCaf = globalThis.cancelAnimationFrame; - globalThis.requestAnimationFrame = (cb) => { - rafCallback = cb; - return 1; - }; - globalThis.cancelAnimationFrame = () => {}; - - let count = 0; - const s = createReflowScheduler(() => count++); - s.schedule(); - s.schedule(); - s.schedule(); - - expect(count).toBe(0); - rafCallback!(0); - expect(count).toBe(1); - - globalThis.requestAnimationFrame = origRaf; - globalThis.cancelAnimationFrame = origCaf; - vi.useRealTimers(); - }); - - it('cancel() calls cancelAnimationFrame with the scheduled handle', () => { - const origRaf = globalThis.requestAnimationFrame; - const origCaf = globalThis.cancelAnimationFrame; - globalThis.requestAnimationFrame = () => 42; - let cancelled: number | null = null; - globalThis.cancelAnimationFrame = (id) => { - cancelled = id; - }; - - const s = createReflowScheduler(() => {}); - s.schedule(); - s.cancel(); - - expect(cancelled).toBe(42); - - globalThis.requestAnimationFrame = origRaf; - globalThis.cancelAnimationFrame = origCaf; - }); -}); diff --git a/src/lib/flip/tracking/scheduler.ts b/src/lib/flip/tracking/scheduler.ts deleted file mode 100644 index 11b0e37..0000000 --- a/src/lib/flip/tracking/scheduler.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * RAF-batched scheduler. - * - * Multiple `schedule()` calls within the same frame coalesce into a single - * `task()` invocation on the next animation frame. - */ - -export interface ReflowScheduler { - /** Request a deferred run; coalesces with any pending request. */ - schedule: () => void; - /** Drop any pending run (without invoking the task). */ - cancel: () => void; -} - -export const createReflowScheduler = (task: () => void): ReflowScheduler => { - let handle: number | null = null; - - const schedule = (): void => { - if (handle != null) return; - if (typeof requestAnimationFrame !== 'function') { - // Non-DOM environments — run synchronously so tests still observe the - // effect. Real browsers always have RAF. - task(); - return; - } - handle = requestAnimationFrame(() => { - handle = null; - task(); - }); - }; - - const cancel = (): void => { - if (handle == null) return; - cancelAnimationFrame(handle); - handle = null; - }; - - return { schedule, cancel }; -}; diff --git a/src/lib/flip/types.ts b/src/lib/flip/types.ts index f981fb8..bd6770d 100644 --- a/src/lib/flip/types.ts +++ b/src/lib/flip/types.ts @@ -1,7 +1,7 @@ import type { Attachment } from 'svelte/attachments'; -import type { FlipRect, FlipRectPair } from '$lib/animate/flip'; -import type { EasingFn, MotionElement } from '$lib/shared/types'; -import type { ObserverManager } from './tracking/observer-manager'; +import type { ClassValue } from 'svelte/elements'; +import type { FlipRect, FlipRectPair } from './geometry'; +import type { EasingFn, MotionElement } from '../shared/types'; // --------------------------------------------------------------------------- // Core geometry @@ -17,8 +17,6 @@ export type FlipEasing = EasingFn | string; export type FlipDuration = number | ((distance: number, rects: FlipRectPair) => number); -export type FlipAuto = false | (() => void) | ObserverManager; - /** * Controls whether a particular reflow cycle should be skipped (no animation * played). `true` skips every cycle; a function receives the zero-based render @@ -50,13 +48,24 @@ export interface FlipOptions { */ layoutId?: string; /** - * Control automatic layout tracking. + * Classes applied to the element by the attachment. The thunk is tracked, so + * changing the rune state it reads writes the classes and animates the + * resulting layout change in the same tick. + * + * Use this *or* a dynamic `class={…}` in markup, not both — Svelte assigns + * `className` wholesale and would drop these tokens. A static `class="…"` + * is safe. * - * - `false` / omitted: no automatic tracking - * - `() => void`: remeasure whenever rune state read inside the callback changes - * - `ObserverManager`: use the provided observer manager (see `createObserverManager`) + * @example `class: () => ({ 'is-open': open })` */ - auto?: FlipAuto; + class?: () => ClassValue; + /** + * Inline style declarations applied to the element by the attachment, as CSS + * text. Tracked and measured exactly like {@link FlipOptions.class}. + * + * @example `style: () => (open ? 'height: 320px' : 'height: 64px')` + */ + style?: () => string; /** Disable animation entirely (still tracks rects). */ disabled?: boolean; /** @@ -107,14 +116,7 @@ export interface FlipAnimateArgs { // Public scope API // --------------------------------------------------------------------------- -export interface CreateFlipScopeOptions { - /** Time in ms a layoutId rect remains valid after unmount. Default: 250. */ - layoutTtlMs?: number; -} - export interface FlipScope { /** Attachment factory bound to this scope's shared-layout registry. */ flip: (input?: FlipOptionsInput) => Attachment; - /** Drop all stored layouts (useful between routes / tests). */ - clear: () => void; } diff --git a/src/lib/gestures/README.md b/src/lib/gestures/README.md deleted file mode 100644 index 896e89f..0000000 --- a/src/lib/gestures/README.md +++ /dev/null @@ -1,149 +0,0 @@ -# gestures - -Pointer-gesture attachments for Svelte 5. Each is a plain `{@attach}` — no component wrapper — and composes with [`animate()`](../animate/README.md) on the same element through the shared `--motion-*` transform chain. - -| File | Responsibility | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `draggable.ts` | `draggable(options)` — pointer drag that writes `--motion-x` / `--motion-y` (so it never clobbers sibling animations). On release it hands the live pointer velocity to a [`SpringValue`](../animate/README.md), giving natural momentum and elastic snap-back. Supports axis lock, static or reactive `constraints`, rubber-band `elastic`, `snapToOrigin`, and start/move/end callbacks. | -| `move.ts` | `moveable(options)` — tracks the pointer over the element (no button) and reports its position normalised from the centre (`nx` / `ny` in `-1…1`), for tilt, parallax and spotlight effects. Optionally applies a springy "magnetic" pull via `--motion-x` / `--motion-y`. Touch filtered out by default. | -| `swipe.ts` | `swipeable(options)` — discrete directional fling. Tracks press travel and live velocity, then fires `onSwipe('left' \| 'right' \| 'up' \| 'down', info)` once a distance _or_ velocity threshold is cleared. Supports `axis` lock. For swipe-to-dismiss, card stacks and carousel paging. | -| `hover.ts` | `hoverable(options)` — `pointerenter` / `pointerleave` with touch "sticky hover" filtered out by default (`includeTouch` to opt in). | -| `focus.ts` | `focusable(options)` — the keyboard counterpart of `hoverable`. Symmetric `onFocusStart` / `onFocusEnd` with `:focus-within` semantics (no flicker as focus moves between children), so hover motion has an accessible equivalent. | -| `press.ts` | `pressable(options)` — press with native cancel semantics: a press that drifts off the element fires `onPressEnd(pressed=false)` and skips `onPress`. Also exposes `onLongPress` (held past `longPressDelay`, then suppresses the click) and `onDoubleTap`. Pointer capture tracks the same pointer throughout. | -| `pinch.ts` | `pinchable(options)` — two-pointer pinch-zoom + rotate. Tracks the distance/angle between two active pointers and reports live `scale` / `rotation`, optionally writing `--motion-scale` / `--motion-rotate` so it composes with `animate()`. Supports `scaleBounds`, `rotate: false`, and start/move/end callbacks. | -| `wheel.ts` | `wheelable(options)` — desktop wheel / trackpad zoom; the pointer-free counterpart of `pinchable`. Turns `wheel` deltas (incl. `ctrlKey` trackpad pinch) into a multiplicative `scale`, optionally writing `--motion-scale` (spring-smoothed by default so notched wheels ramp instead of snapping). Brackets discrete ticks into one gesture with `onStart` / `onEnd`. | -| `reorder.ts` | `reorder({ items, onReorder })` — headless drag-to-reorder. Returns `{ item(value) }`: attach it to each row. The dragged row follows the pointer while siblings slide to open a slot; on drop the array is reordered via `onReorder(next)` and every row FLIPs into its new position. Composes [`draggable`](#) with the [FLIP engine](../flip/README.md). | -| `constraints.ts` | Pure constraint math (`applyConstraint`, `xBounds`, `yBounds`) — clamping and elastic overflow, DOM-free and unit-tested. | -| `pointer-capture.ts` | `capture` / `release` wrappers that swallow the `NotFoundError` thrown when a pointer id is no longer active. | - -```svelte - - - -
- - - -``` - -### `draggable` options - -| Option | Default | Effect | -| -------------- | --------------- | ------------------------------------------------------------------------- | -| `axis` | `'both'` | Lock dragging to `'x'` or `'y'`. Sets `touch-action` accordingly. | -| `constraints` | — | `{ left, right, top, bottom }` bounds, or a thunk re-read per drag. | -| `elastic` | `0` | Resistance past constraints: `0` = wall, `1` = none, `0.2` = rubber-band. | -| `spring` | spring defaults | Physics for release momentum and snap-back. | -| `snapToOrigin` | `false` | Spring back to the start instead of staying put. | -| `momentum` | `true` | Carry release velocity into the settle spring. | - -### `moveable`, `swipeable` & `focusable` - -```svelte - - - - - - -
animate(el, { rotateY: nx * 12 }) })}>
- - -
dir === 'left' && dismiss() })}>
- - - -``` - -| `moveable` option | Default | Effect | -| ----------------- | ------- | -------------------------------------------------------------------------------- | -| `applyTransform` | `false` | Apply a springy magnetic pull via `--motion-x` / `--motion-y` (report-only off). | -| `strength` | `0.3` | Magnetic pull as a fraction of the pointer's offset from centre. | -| `spring` | — | Spring used for the snap back to rest on leave. | -| `includeTouch` | `false` | Also react to touch pointers. | - -| `swipeable` option | Default | Effect | -| ------------------- | -------- | -------------------------------------------------------- | -| `axis` | `'both'` | Restrict recognised swipes to `'x'` or `'y'`. | -| `threshold` | `30` | Minimum travel (px) to count as a swipe. | -| `velocityThreshold` | `300` | Release speed (px/s) that counts regardless of distance. | - -`focusable` mirrors `hoverable`: `onFocusStart` / `onFocusEnd`, with `disabled` to opt out. - -`pressable` adds two discrete variants on the same lifecycle: - -| `pressable` option | Default | Effect | -| ------------------ | ------- | ------------------------------------------------------------------------------------------ | -| `onLongPress` | — | Fires when a press is held past `longPressDelay`, then suppresses the following `onPress`. | -| `onDoubleTap` | — | Fires when two completed presses land within `doubleTapDelay`. | -| `longPressDelay` | `500` | Hold time (ms) before a long press fires. | -| `doubleTapDelay` | `300` | Maximum gap (ms) between two presses to count as a double tap. | - -### `pinchable`, `wheelable` & `reorder` - -```svelte - - - - - - - -{#each list as value (value)} -
{value}
-{/each} -``` - -| `pinchable` option | Default | Effect | -| ------------------ | ------- | --------------------------------------------------- | -| `applyTransform` | `true` | Write `--motion-scale` / `--motion-rotate` live. | -| `rotate` | `true` | Track rotation as well as scale. | -| `scaleBounds` | — | `{ min, max }` clamp on the reported/applied scale. | - -| `wheelable` option | Default | Effect | -| ------------------ | ------- | --------------------------------------------------- | -| `applyTransform` | `true` | Write `--motion-scale` on each tick. | -| `scaleBounds` | — | `{ min, max }` clamp on the reported/applied scale. | -| `speed` | `0.01` | Zoom sensitivity per wheel unit. | -| `requireCtrl` | `false` | Only act when `ctrlKey` is held (trackpad pinch). | -| `preventDefault` | `true` | Stop the page scrolling / browser-zooming. | -| `smooth` | `true` | Spring-ease applied scale; `false` or `SpringOptions`. | - -| `reorder` option | Default | Effect | -| ---------------- | ------- | ---------------------------------------------------------- | -| `items` | — | Reactive thunk returning the current ordered list. | -| `onReorder` | — | Called with the new array when a drag drops on a new slot. | -| `axis` | `'y'` | `'x'` for horizontal lists. | -| `duration` | `220` | Settle (FLIP-into-slot) length in ms. | diff --git a/src/lib/gestures/draggable.ts b/src/lib/gestures/draggable.ts index ea4dad7..a6d1155 100644 --- a/src/lib/gestures/draggable.ts +++ b/src/lib/gestures/draggable.ts @@ -11,15 +11,14 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '$lib/shared/browser'; -import type { MotionElement } from '$lib/animate'; -import type { SpringOptions } from '$lib/shared/types'; -import { createSpringValue } from '$lib/animate/spring-value'; -import { capture, release } from './pointer-capture'; -import { - ensurePropertiesRegistered, - ensureTransformWired -} from '$lib/animate/properties/transform-setup'; +import { isBrowser } from '../shared/browser'; +import { listen } from '../shared/listen'; +import { trackVelocity } from './velocity'; +import type { MotionElement } from '../animate'; +import type { SpringOptions } from '../shared/types'; +import { createSpringValue } from '../animate/spring-value'; +import { capture, lockTouchAction, release } from './pointer-capture'; +import { wireTransform } from '../animate/properties/transform-setup'; import { applyConstraint, xBounds, yBounds, type DragConstraints } from './constraints'; export type DragAxis = 'x' | 'y' | 'both'; @@ -46,16 +45,11 @@ export interface DraggableOptions { snapToOrigin?: boolean; /** Carry release velocity into the settle spring. Default true. */ momentum?: boolean; - /** Disable dragging without removing the attachment. */ - disabled?: boolean; onStart?: (info: DragInfo, element: MotionElement) => void; onMove?: (info: DragInfo, element: MotionElement) => void; onEnd?: (info: DragInfo, element: MotionElement) => void; } -const touchActionFor = (axis: DragAxis): string => - axis === 'x' ? 'pan-y' : axis === 'y' ? 'pan-x' : 'none'; - /** Create a draggable attachment. */ export const draggable = (options: DraggableOptions = {}): Attachment => { const { @@ -65,19 +59,16 @@ export const draggable = (options: DraggableOptions = {}): Attachment { - if (!isBrowser() || disabled) return; + if (!isBrowser()) return; - ensurePropertiesRegistered(); - ensureTransformWired(element); - const prevTouchAction = element.style.touchAction; - element.style.touchAction = touchActionFor(axis); + wireTransform(element); + const unlockTouchAction = lockTouchAction(element, axis); const lockX = axis === 'y'; const lockY = axis === 'x'; @@ -97,12 +88,7 @@ export const draggable = (options: DraggableOptions = {}): Attachment typeof constraints === 'function' ? constraints() : (constraints ?? {}); @@ -110,8 +96,8 @@ export const draggable = (options: DraggableOptions = {}): Attachment ({ x, y, - velocityX: velX, - velocityY: velY + velocityX: velocity.x, + velocityY: velocity.y }); const onPointerDown = (event: PointerEvent): void => { @@ -124,24 +110,13 @@ export const draggable = (options: DraggableOptions = {}): Attachment { if (!dragging || event.pointerId !== pointerId) return; - const dt = (event.timeStamp - lastT) / 1000; - if (dt > 0) { - velX = (event.clientX - lastX) / dt; - velY = (event.clientY - lastY) / dt; - } - lastX = event.clientX; - lastY = event.clientY; - lastT = event.timeStamp; + velocity.sample(event); const c = bounds(); if (!lockX) { @@ -158,8 +133,8 @@ export const draggable = (options: DraggableOptions = {}): Attachment { - element.removeEventListener('pointerdown', down); - element.removeEventListener('pointermove', move); - element.removeEventListener('pointerup', up); - element.removeEventListener('pointercancel', up); + unlisten(); unsubX(); unsubY(); springX.stop(); springY.stop(); - element.style.touchAction = prevTouchAction; + unlockTouchAction(); }; }; }; diff --git a/src/lib/gestures/focus.ts b/src/lib/gestures/focus.ts index 2ea9fd1..a0bedf0 100644 --- a/src/lib/gestures/focus.ts +++ b/src/lib/gestures/focus.ts @@ -19,22 +19,21 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '$lib/shared/browser'; -import type { MotionElement } from '$lib/animate'; +import { isBrowser } from '../shared/browser'; +import { listen } from '../shared/listen'; +import type { MotionElement } from '../animate'; export interface FocusableOptions { onFocusStart?: (element: MotionElement, event: FocusEvent) => void; onFocusEnd?: (element: MotionElement, event: FocusEvent) => void; - /** Disable without removing the attachment. */ - disabled?: boolean; } /** Create a focus attachment. */ export const focusable = (options: FocusableOptions = {}): Attachment => { - const { onFocusStart, onFocusEnd, disabled = false } = options; + const { onFocusStart, onFocusEnd } = options; return (element) => { - if (!isBrowser() || disabled) return; + if (!isBrowser()) return; let focused = false; @@ -53,14 +52,6 @@ export const focusable = (options: FocusableOptions = {}): Attachment { - element.removeEventListener('focusin', focusIn); - element.removeEventListener('focusout', focusOut); - }; + return listen(element, { focusin: onFocusIn, focusout: onFocusOut }); }; }; diff --git a/src/lib/gestures/hover.ts b/src/lib/gestures/hover.ts index a98e4cc..761a25a 100644 --- a/src/lib/gestures/hover.ts +++ b/src/lib/gestures/hover.ts @@ -15,24 +15,23 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '$lib/shared/browser'; -import type { MotionElement } from '$lib/animate'; +import { isBrowser } from '../shared/browser'; +import { listen } from '../shared/listen'; +import type { MotionElement } from '../animate'; export interface HoverableOptions { onHoverStart?: (element: MotionElement, event: PointerEvent) => void; onHoverEnd?: (element: MotionElement, event: PointerEvent) => void; /** Also react to touch pointers. Default false (mouse/pen only). */ includeTouch?: boolean; - /** Disable without removing the attachment. */ - disabled?: boolean; } /** Create a hover attachment. */ export const hoverable = (options: HoverableOptions = {}): Attachment => { - const { onHoverStart, onHoverEnd, includeTouch = false, disabled = false } = options; + const { onHoverStart, onHoverEnd, includeTouch = false } = options; return (element) => { - if (!isBrowser() || disabled) return; + if (!isBrowser()) return; const ignore = (event: PointerEvent): boolean => !includeTouch && event.pointerType === 'touch'; @@ -43,14 +42,6 @@ export const hoverable = (options: HoverableOptions = {}): Attachment { - element.removeEventListener('pointerenter', enter); - element.removeEventListener('pointerleave', leave); - }; + return listen(element, { pointerenter: onEnter, pointerleave: onLeave }); }; }; diff --git a/src/lib/gestures/move.ts b/src/lib/gestures/move.ts index 2558e76..9aee447 100644 --- a/src/lib/gestures/move.ts +++ b/src/lib/gestures/move.ts @@ -18,14 +18,12 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '$lib/shared/browser'; -import type { MotionElement } from '$lib/animate'; -import type { SpringOptions } from '$lib/shared/types'; -import { createSpringValue } from '$lib/animate/spring-value'; -import { - ensurePropertiesRegistered, - ensureTransformWired -} from '$lib/animate/properties/transform-setup'; +import { isBrowser } from '../shared/browser'; +import { listen } from '../shared/listen'; +import type { MotionElement } from '../animate'; +import type { SpringOptions } from '../shared/types'; +import { createSpringValue } from '../animate/spring-value'; +import { wireTransform } from '../animate/properties/transform-setup'; export interface MoveInfo { /** Pointer position in client coordinates. */ @@ -52,8 +50,6 @@ export interface MoveableOptions { spring?: SpringOptions; /** Also react to touch pointers. Default `false` (mouse/pen only). */ includeTouch?: boolean; - /** Disable without removing the attachment. */ - disabled?: boolean; onMoveStart?: (info: MoveInfo, element: MotionElement) => void; onMove?: (info: MoveInfo, element: MotionElement) => void; onMoveEnd?: (element: MotionElement) => void; @@ -66,14 +62,13 @@ export const moveable = (options: MoveableOptions = {}): Attachment { - if (!isBrowser() || disabled) return; + if (!isBrowser()) return; // Only spring the axes we actually write, mirroring draggable. const springX = applyTransform ? createSpringValue({ ...spring, initial: 0 }) : null; @@ -81,8 +76,7 @@ export const moveable = (options: MoveableOptions = {}): Attachment {}; let unsubY = (): void => {}; if (applyTransform) { - ensurePropertiesRegistered(); - ensureTransformWired(element); + wireTransform(element); unsubX = springX!.subscribe((v) => element.style.setProperty('--motion-x', `${v}px`)); unsubY = springY!.subscribe((v) => element.style.setProperty('--motion-y', `${v}px`)); } @@ -128,17 +122,14 @@ export const moveable = (options: MoveableOptions = {}): Attachment { - element.removeEventListener('pointerenter', enter); - element.removeEventListener('pointermove', move); - element.removeEventListener('pointerleave', leave); + unlisten(); unsubX(); unsubY(); springX?.stop(); diff --git a/src/lib/gestures/pinch.ts b/src/lib/gestures/pinch.ts index b4bc9d1..6a2ceb5 100644 --- a/src/lib/gestures/pinch.ts +++ b/src/lib/gestures/pinch.ts @@ -11,13 +11,12 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '$lib/shared/browser'; -import type { MotionElement } from '$lib/animate'; -import { capture, release } from './pointer-capture'; -import { - ensurePropertiesRegistered, - ensureTransformWired -} from '$lib/animate/properties/transform-setup'; +import { isBrowser } from '../shared/browser'; +import { listen } from '../shared/listen'; +import { applyConstraint, type AxisBounds } from './constraints'; +import type { MotionElement } from '../animate'; +import { capture, lockTouchAction, release } from './pointer-capture'; +import { wireTransform } from '../animate/properties/transform-setup'; export interface PinchInfo { /** Distance ratio of the two pointers relative to gesture start. */ @@ -32,11 +31,9 @@ export interface PinchableOptions { /** Track rotation alongside scale. Default `true`. */ rotate?: boolean; /** Clamp the reported (and applied) scale. */ - scaleBounds?: { min?: number; max?: number }; + scaleBounds?: AxisBounds; /** Write `--motion-scale` / `--motion-rotate` on move. Default `true`. */ applyTransform?: boolean; - /** Disable pinching without removing the attachment. */ - disabled?: boolean; onStart?: (info: PinchInfo, element: MotionElement) => void; onMove?: (info: PinchInfo, element: MotionElement) => void; onEnd?: (info: PinchInfo, element: MotionElement) => void; @@ -50,35 +47,17 @@ const distance = (ax: number, ay: number, bx: number, by: number): number => const angle = (ax: number, ay: number, bx: number, by: number): number => (Math.atan2(by - ay, bx - ax) * 180) / Math.PI; -const clampScale = (scale: number, bounds?: { min?: number; max?: number }): number => { - if (!bounds) return scale; - let next = scale; - if (bounds.min !== undefined) next = Math.max(bounds.min, next); - if (bounds.max !== undefined) next = Math.min(bounds.max, next); - return next; -}; - /** Create a pinchable attachment. */ export const pinchable = (options: PinchableOptions = {}): Attachment => { - const { - rotate = true, - scaleBounds, - applyTransform = true, - disabled = false, - onStart, - onMove, - onEnd - } = options; + const { rotate = true, scaleBounds, applyTransform = true, onStart, onMove, onEnd } = options; return (element) => { - if (!isBrowser() || disabled) return; + if (!isBrowser()) return; if (applyTransform) { - ensurePropertiesRegistered(); - ensureTransformWired(element); + wireTransform(element); } - const prevTouchAction = element.style.touchAction; - element.style.touchAction = 'none'; + const unlockTouchAction = lockTouchAction(element, 'both'); // Live pointer positions keyed by pointerId; at most two are tracked. const points = new Map(); @@ -95,6 +74,16 @@ export const pinchable = (options: PinchableOptions = {}): Attachment { + const [a, b] = [...points.values()]; + const scale = applyConstraint( + startDistance > 0 ? distance(a.x, a.y, b.x, b.y) / startDistance : 1, + scaleBounds ?? {} + ); + return info(scale, rotate ? angle(a.x, a.y, b.x, b.y) - startAngle : 0); + }; + const onPointerDown = (event: PointerEvent): void => { if (points.size >= 2) return; points.set(event.pointerId, { x: event.clientX, y: event.clientY }); @@ -116,55 +105,35 @@ export const pinchable = (options: PinchableOptions = {}): Attachment 0 ? currentDistance / startDistance : 1, - scaleBounds - ); - const rotation = rotate ? angle(a.x, a.y, b.x, b.y) - startAngle : 0; - + const current = read(); if (applyTransform) { // Baseline scale of 1; multiplying keeps composition explicit. - element.style.setProperty('--motion-scale', `${1 * scale}`); - if (rotate) element.style.setProperty('--motion-rotate', `${rotation}deg`); + element.style.setProperty('--motion-scale', `${1 * current.scale}`); + if (rotate) element.style.setProperty('--motion-rotate', `${current.rotation}deg`); } - onMove?.(info(scale, rotation), element); + onMove?.(current, element); }; const onPointerUp = (event: PointerEvent): void => { if (!points.has(event.pointerId)) return; release(element, event.pointerId); - if (pinching) { - const [a, b] = [...points.values()]; - const currentDistance = distance(a.x, a.y, b.x, b.y); - const scale = clampScale( - startDistance > 0 ? currentDistance / startDistance : 1, - scaleBounds - ); - const rotation = rotate ? angle(a.x, a.y, b.x, b.y) - startAngle : 0; - onEnd?.(info(scale, rotation), element); - } + if (pinching) onEnd?.(read(), element); // Lifting either pointer ends the gesture and resets. pinching = false; points.delete(event.pointerId); }; - const down = onPointerDown as EventListener; - const move = onPointerMove as EventListener; - const up = onPointerUp as EventListener; - element.addEventListener('pointerdown', down); - element.addEventListener('pointermove', move); - element.addEventListener('pointerup', up); - element.addEventListener('pointercancel', up); + const unlisten = listen(element, { + pointerdown: onPointerDown, + pointermove: onPointerMove, + pointerup: onPointerUp, + pointercancel: onPointerUp + }); return () => { - element.removeEventListener('pointerdown', down); - element.removeEventListener('pointermove', move); - element.removeEventListener('pointerup', up); - element.removeEventListener('pointercancel', up); + unlisten(); points.clear(); - element.style.touchAction = prevTouchAction; + unlockTouchAction(); }; }; }; diff --git a/src/lib/gestures/pointer-capture.ts b/src/lib/gestures/pointer-capture.ts index fd80bab..94bed1a 100644 --- a/src/lib/gestures/pointer-capture.ts +++ b/src/lib/gestures/pointer-capture.ts @@ -1,5 +1,6 @@ /** - * Pointer-capture helpers that never throw. + * Shared DOM plumbing for the pointer-gesture attachments: capture that never + * throws, and the `touch-action` lock every drag-like gesture needs. * * `setPointerCapture` / `releasePointerCapture` raise `NotFoundError` when the * pointer id is not active (common with synthetic events in tests, and with @@ -7,6 +8,28 @@ * teardown is always safe. */ +import { restoreStyleProp, saveStyleProp } from '../shared/inline-style'; +import type { MotionElement } from '../shared/types'; + +/** The axis a gesture owns; the browser keeps panning on the other one. */ +export type GestureAxis = 'x' | 'y' | 'both'; + +const TOUCH_ACTION: Record = { + x: 'pan-y', + y: 'pan-x', + both: 'none' +}; + +/** + * Hand the gesture's axis to us rather than the browser's native panning, and + * return the teardown that puts the element's own `touch-action` back. + */ +export const lockTouchAction = (element: MotionElement, axis: GestureAxis): (() => void) => { + const saved = saveStyleProp(element.style, 'touch-action'); + element.style.setProperty('touch-action', TOUCH_ACTION[axis]); + return () => restoreStyleProp(element.style, 'touch-action', saved); +}; + export const capture = (element: Element, pointerId: number): void => { try { element.setPointerCapture(pointerId); @@ -17,9 +40,7 @@ export const capture = (element: Element, pointerId: number): void => { export const release = (element: Element, pointerId: number): void => { try { - if (element.hasPointerCapture(pointerId)) { - element.releasePointerCapture(pointerId); - } + element.releasePointerCapture(pointerId); } catch { // Already released or never captured. } diff --git a/src/lib/gestures/press.ts b/src/lib/gestures/press.ts index e34d8ba..87ca29a 100644 --- a/src/lib/gestures/press.ts +++ b/src/lib/gestures/press.ts @@ -23,8 +23,9 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '$lib/shared/browser'; -import type { MotionElement } from '$lib/animate'; +import { isBrowser } from '../shared/browser'; +import { listen } from '../shared/listen'; +import type { MotionElement } from '../animate'; import { capture, release } from './pointer-capture'; export interface PressableOptions { @@ -42,8 +43,6 @@ export interface PressableOptions { longPressDelay?: number; /** Maximum gap between two presses to count as a double tap, ms. Default `300`. */ doubleTapDelay?: number; - /** Disable without removing the attachment. */ - disabled?: boolean; } /** Create a press attachment. */ @@ -55,12 +54,11 @@ export const pressable = (options: PressableOptions = {}): Attachment { - if (!isBrowser() || disabled) return; + if (!isBrowser()) return; let pressed = false; let pointerId = -1; @@ -121,18 +119,15 @@ export const pressable = (options: PressableOptions = {}): Attachment { clearLongPress(); - element.removeEventListener('pointerdown', down); - element.removeEventListener('pointerup', up); - element.removeEventListener('pointercancel', cancel); + unlisten(); }; }; }; diff --git a/src/lib/gestures/reorder.svelte.test.ts b/src/lib/gestures/reorder.svelte.test.ts index 9af3537..521ab9e 100644 --- a/src/lib/gestures/reorder.svelte.test.ts +++ b/src/lib/gestures/reorder.svelte.test.ts @@ -3,7 +3,7 @@ * browser. Runs in the `client` project. */ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { reorder } from './reorder'; let container: HTMLElement | null = null; @@ -29,6 +29,7 @@ const setup = () => { }; afterEach(() => { + vi.restoreAllMocks(); container?.remove(); container = null; }); @@ -51,6 +52,30 @@ describe('reorder()', () => { expect(order).toEqual(['b', 'a', 'c']); }); + it('composes drag offsets without replacing consumer transforms and restores owned styles', () => { + const { values, els } = setup(); + let order = [...values]; + els[0]!.style.transform = 'rotate(12deg)'; + els[1]!.style.transition = 'opacity 100ms'; + const r = reorder({ items: () => order, onReorder: (next) => (order = next) }); + els.forEach((el, i) => r.item(values[i]!)(el)); + + els[0]!.dispatchEvent(pointer('pointerdown', 20)); + els[0]!.dispatchEvent(pointer('pointermove', 70)); + + expect(els[0]!.style.transform).toBe('rotate(12deg)'); + expect(els[0]!.style.getPropertyValue('--motion-reorder-y')).toBe('50px'); + expect(Number.parseFloat(els[1]!.style.getPropertyValue('--motion-reorder-y'))).toBeCloseTo( + -40 + ); + + els[0]!.dispatchEvent(pointer('pointerup', 70)); + expect(order).toEqual(['b', 'a', 'c']); + expect(els[0]!.style.transform).toBe('rotate(12deg)'); + expect(els[0]!.style.getPropertyValue('--motion-reorder-y')).toBe(''); + expect(els[1]!.style.transition).toBe('opacity 100ms'); + }); + it('does not reorder when the drag stays within its own slot', () => { const { values, els } = setup(); let order = [...values]; @@ -85,6 +110,75 @@ describe('reorder()', () => { expect(order).toEqual(['b', 'c', 'a']); }); + it('uses actual slot centers for variable-size rows', () => { + const { values, els } = setup(); + els[0]!.style.height = '20px'; + els[1]!.style.top = '20px'; + els[1]!.style.height = '100px'; + els[2]!.style.top = '120px'; + let order = [...values]; + const r = reorder({ items: () => order, onReorder: (next) => (order = next) }); + els.forEach((el, i) => r.item(values[i]!)(el)); + + // The second row's center is 70px, not the old average-stride slot at 80px. + els[0]!.dispatchEvent(pointer('pointerdown', 10)); + els[0]!.dispatchEvent(pointer('pointermove', 60)); + els[0]!.dispatchEvent(pointer('pointerup', 60)); + + expect(order).toEqual(['b', 'a', 'c']); + }); + + it('supports recreated or duplicate values through a stable getKey', () => { + const { els } = setup(); + let order = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; + const r = reorder({ + items: () => order, + getKey: (item) => item.id, + onReorder: (next) => (order = next) + }); + order.forEach((item, i) => r.item(item)(els[i]!)); + + els[0]!.dispatchEvent(pointer('pointerdown', 20)); + els[0]!.dispatchEvent(pointer('pointermove', 70)); + els[0]!.dispatchEvent(pointer('pointerup', 70)); + + expect(order.map((item) => item.id)).toEqual(['b', 'a', 'c']); + }); + + it('cancels an active drag and restores owned styles on detach', () => { + const { values, els } = setup(); + let order = [...values]; + const r = reorder({ items: () => order, onReorder: (next) => (order = next) }); + const cleanup = r.item(values[0]!)(els[0]!); + els.slice(1).forEach((el, i) => r.item(values[i + 1]!)(el)); + + els[0]!.dispatchEvent(pointer('pointerdown', 20)); + els[0]!.dispatchEvent(pointer('pointermove', 70)); + cleanup?.(); + + expect(els[0]!.style.getPropertyValue('--motion-reorder-y')).toBe(''); + expect(order).toEqual(['a', 'b', 'c']); + }); + + it('cancels the scheduled FLIP handoff when detached after drop', () => { + const { values, els } = setup(); + let order = [...values]; + const requestFrame = vi.spyOn(window, 'requestAnimationFrame').mockReturnValue(42); + const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame'); + const r = reorder({ items: () => order, onReorder: (next) => (order = next) }); + const cleanup = r.item(values[0]!)(els[0]!); + els.slice(1).forEach((el, i) => r.item(values[i + 1]!)(el)); + + els[0]!.dispatchEvent(pointer('pointerdown', 20)); + els[0]!.dispatchEvent(pointer('pointermove', 70)); + els[0]!.dispatchEvent(pointer('pointerup', 70)); + cleanup?.(); + + expect(order).toEqual(['b', 'a', 'c']); + expect(requestFrame).toHaveBeenCalledTimes(1); + expect(cancelFrame).toHaveBeenCalledWith(42); + }); + it('detaches cleanly (cleanup removes the listener)', () => { const { values, els } = setup(); let order = [...values]; diff --git a/src/lib/gestures/reorder.test.ts b/src/lib/gestures/reorder.test.ts new file mode 100644 index 0000000..9d871a5 --- /dev/null +++ b/src/lib/gestures/reorder.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { nearestCenterIndex } from './reorder'; + +describe('nearestCenterIndex()', () => { + it('finds the same nearest slot at either boundary and between centers', () => { + const centers = [10, 40, 90, 160]; + expect(nearestCenterIndex(centers, -10)).toBe(0); + expect(nearestCenterIndex(centers, 67)).toBe(2); + expect(nearestCenterIndex(centers, 999)).toBe(3); + }); + + it('retains nearest-slot semantics for non-linear arrangements', () => { + expect(nearestCenterIndex([10, 90, 40], 35)).toBe(2); + }); + + it('reports no slot for an empty list', () => { + expect(nearestCenterIndex([], 0)).toBe(-1); + }); +}); diff --git a/src/lib/gestures/reorder.ts b/src/lib/gestures/reorder.ts index c5cd8d0..3db5b66 100644 --- a/src/lib/gestures/reorder.ts +++ b/src/lib/gestures/reorder.ts @@ -11,31 +11,47 @@ * @example * ```svelte * * * {#each list as value (value)} - *
{value}
+ *
{value}
* {/each} * ``` */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '$lib/shared/browser'; -import { snapshotRect, flipFrom } from '$lib/flip'; -import type { EasingFn } from '$lib/shared/types'; -import { capture, release } from './pointer-capture'; +import { isBrowser } from '../shared/browser'; +import { listen } from '../shared/listen'; +import { snapshotRect, flipFrom } from '../flip'; +import type { EasingFn } from '../shared/types'; +import { wireTransform } from '../animate/properties/transform-setup'; +import { restoreStyleProp, saveStyleProp, type SavedStyleProp } from '../shared/inline-style'; +import { capture, lockTouchAction, release } from './pointer-capture'; + +/** Find the nearest slot center to `target`. Linear — reorder lists are short. */ +export const nearestCenterIndex = (centers: readonly number[], target: number): number => { + if (centers.length === 0) return -1; + let nearest = 0; + for (let i = 1; i < centers.length; i++) { + if (Math.abs(centers[i]! - target) < Math.abs(centers[nearest]! - target)) nearest = i; + } + return nearest; +}; export interface ReorderOptions { /** Reactive thunk returning the current ordered list. */ items: () => T[]; /** Called with the new order when a drag drops on a different slot. */ onReorder: (next: T[]) => void; + /** + * Returns the stable, unique identity for each row. Required when item values + * are duplicated or recreated; defaults to the value itself. + */ + getKey?: (item: T) => unknown; /** Drag axis. Default `'y'`. */ axis?: 'x' | 'y'; - /** Disable dragging without detaching. */ - disabled?: boolean; /** Settle animation length in ms (the FLIP into the new slot). Default 220. */ duration?: number; /** Settle easing. */ @@ -55,51 +71,87 @@ const arrayMove = (arr: readonly T[], from: number, to: number): T[] => { return next; }; -const clamp = (n: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, n)); +const REORDER_X = '--motion-reorder-x'; +const REORDER_Y = '--motion-reorder-y'; +const DRAG_STYLE_PROPERTIES = [REORDER_X, REORDER_Y, 'transition', 'position', 'z-index'] as const; +type DragStyleProperty = (typeof DRAG_STYLE_PROPERTIES)[number]; +type DragStyleSnapshot = Record; + +const snapshotDragStyles = (element: HTMLElement): DragStyleSnapshot => + Object.fromEntries( + DRAG_STYLE_PROPERTIES.map((property) => [property, saveStyleProp(element.style, property)]) + ) as DragStyleSnapshot; + +const restoreDragStyles = (element: HTMLElement, snapshot: DragStyleSnapshot): void => { + for (const property of DRAG_STYLE_PROPERTIES) { + restoreStyleProp(element.style, property, snapshot[property]); + } +}; + +const setReorderOffset = (element: HTMLElement, horizontal: boolean, offset: number): void => { + element.style.setProperty(horizontal ? REORDER_X : REORDER_Y, `${offset}px`); +}; interface DragState { pointerId: number; el: HTMLElement; from: number; over: number; - stride: number; + centers: number[]; start: number; els: HTMLElement[]; - centers: number[]; + styles: Map; + /** Detaches the move/up/cancel listeners this drag installed. */ + unlisten: () => void; } /** Create a reorder controller for one list. */ export const reorder = (options: ReorderOptions): ReorderHandle => { const { axis = 'y', duration = 220 } = options; const horizontal = axis === 'x'; - const elements = new Map(); + const keyOf = options.getKey ?? ((value: T): unknown => value); + const elements = new Map(); const coordOf = (e: PointerEvent): number => (horizontal ? e.clientX : e.clientY); const centerOf = (r: DOMRect): number => horizontal ? r.left + r.width / 2 : r.top + r.height / 2; - const translate = (px: number): string => - horizontal ? `translateX(${px}px)` : `translateY(${px}px)`; let drag: DragState | null = null; + // The data callback commits synchronously, but the framework needs one frame + // to render that order before FLIP can measure it. Keep that deferred handoff + // owned by this attachment so teardown cannot animate a detached node. + let settleFrame: number | null = null; + + const cancelPendingSettle = (): void => { + if (settleFrame == null) return; + cancelAnimationFrame(settleFrame); + settleFrame = null; + }; + + const restoreDrag = (state: DragState): void => { + for (const node of state.els) restoreDragStyles(node, state.styles.get(node)!); + }; - /** Slide siblings between `from` and `over` to open the drop slot. */ + /** Slide siblings into the dragged row's actual slot, including variable item sizes and gaps. */ const layoutSiblings = (): void => { if (!drag) return; - const { els, from, over, stride } = drag; + const { els, from, over, centers } = drag; for (let i = 0; i < els.length; i++) { - if (i === drag.from) continue; + if (i === from) continue; let shift = 0; - if (from < over && i > from && i <= over) shift = -stride; - else if (over < from && i >= over && i < from) shift = stride; - els[i]!.style.transform = shift ? translate(shift) : ''; + if (from < over && i > from && i <= over) shift = centers[i - 1]! - centers[i]!; + else if (over < from && i >= over && i < from) shift = centers[i + 1]! - centers[i]!; + setReorderOffset(els[i]!, horizontal, shift); } }; const onMove = (e: PointerEvent): void => { if (!drag || e.pointerId !== drag.pointerId) return; + const { centers, from } = drag; const delta = coordOf(e) - drag.start; - drag.el.style.transform = translate(delta); - const over = clamp(drag.from + Math.round(delta / drag.stride), 0, drag.els.length - 1); + setReorderOffset(drag.el, horizontal, delta); + const targetCenter = centers[from]! + delta; + const over = nearestCenterIndex(centers, targetCenter); if (over !== drag.over) { drag.over = over; layoutSiblings(); @@ -108,86 +160,99 @@ export const reorder = (options: ReorderOptions): ReorderHandle => { const endDrag = (e: PointerEvent): void => { if (!drag || e.pointerId !== drag.pointerId) return; - const { el, els, from, over, pointerId } = drag; - el.removeEventListener('pointermove', onMove as EventListener); - el.removeEventListener('pointerup', endDrag as EventListener); - el.removeEventListener('pointercancel', endDrag as EventListener); + const state = drag; + const { el, els, from, over, pointerId } = state; + state.unlisten(); release(el, pointerId); drag = null; - // Capture every row's current (transformed) position, drop the inline - // transforms, commit the reorder, then FLIP each row from where it - // visually was into its freshly-laid-out slot. + // Capture the composed visual position, restore every property this drag + // owns, commit data, then FLIP into the freshly laid-out slots. const fromRects = els.map((node) => snapshotRect(node)); - for (const node of els) { - node.style.transform = ''; - node.style.transition = ''; - } - el.style.zIndex = ''; - el.style.position = ''; + restoreDrag(state); if (over !== from) options.onReorder(arrayMove(options.items(), from, over)); - requestAnimationFrame(() => { + cancelPendingSettle(); + settleFrame = requestAnimationFrame(() => { + settleFrame = null; for (let i = 0; i < els.length; i++) { flipFrom(els[i]!, fromRects[i]!, { duration, easing: options.easing }); } }); }; + const cancelDrag = (): void => { + if (!drag) return; + const state = drag; + const { el, pointerId } = state; + state.unlisten(); + release(el, pointerId); + drag = null; + restoreDrag(state); + }; + const onDown = (el: HTMLElement) => (e: PointerEvent): void => { - if (options.disabled || e.button !== 0 || drag) return; + if (e.button !== 0 || drag) return; const order = options.items(); - const els = order.map((v) => elements.get(v)).filter((n): n is HTMLElement => !!n); - const from = els.indexOf(el); + const keys = order.map(keyOf); + // A value is a safe default key only while it is unique. Refuse an + // ambiguous drag rather than moving the wrong row; callers can supply getKey. + if (new Set(keys).size !== keys.length) return; + const els = keys.map((key) => elements.get(key)); + if (els.some((node): node is undefined => !node)) return; + const rows = els as HTMLElement[]; + const from = rows.indexOf(el); if (from < 0) return; - const rects = els.map((node) => node.getBoundingClientRect()); + const rects = rows.map((node) => node.getBoundingClientRect()); const centers = rects.map(centerOf); - const stride = - els.length > 1 - ? (centers[centers.length - 1]! - centers[0]!) / (els.length - 1) - : horizontal - ? rects[0]!.width - : rects[0]!.height; + const styles = new Map(rows.map((node) => [node, snapshotDragStyles(node)])); drag = { pointerId: e.pointerId, el, from, over: from, - stride, + centers, start: coordOf(e), - els, - centers + els: rows, + styles, + unlisten: listen(el, { + pointermove: onMove, + pointerup: endDrag, + pointercancel: endDrag + }) }; - // Lift the dragged row; give siblings a transition so they glide. + // Reorder owns only its dedicated motion channel and temporary drag + // presentation; existing transform and inline styles are never replaced. el.style.position = 'relative'; el.style.zIndex = '1'; - for (let i = 0; i < els.length; i++) { - if (i !== from) els[i]!.style.transition = `transform ${duration}ms`; + for (let i = 0; i < rows.length; i++) { + if (i !== from) rows[i]!.style.transition = `translate ${duration}ms`; } capture(el, e.pointerId); - el.addEventListener('pointermove', onMove as EventListener); - el.addEventListener('pointerup', endDrag as EventListener); - el.addEventListener('pointercancel', endDrag as EventListener); }; const item = (value: T): Attachment => (el) => { if (!isBrowser()) return; - elements.set(value, el); - el.style.touchAction = horizontal ? 'pan-y' : 'pan-x'; - const down = onDown(el); - el.addEventListener('pointerdown', down as EventListener); + wireTransform(el); + const key = keyOf(value); + elements.set(key, el); + const unlockTouchAction = lockTouchAction(el, horizontal ? 'x' : 'y'); + const unlisten = listen(el, { pointerdown: onDown(el) }); return () => { - el.removeEventListener('pointerdown', down as EventListener); - elements.delete(value); + if (drag?.els.includes(el)) cancelDrag(); + cancelPendingSettle(); + unlisten(); + if (elements.get(key) === el) elements.delete(key); + unlockTouchAction(); }; }; diff --git a/src/lib/gestures/swipe.ts b/src/lib/gestures/swipe.ts index ee4ad33..f2a091e 100644 --- a/src/lib/gestures/swipe.ts +++ b/src/lib/gestures/swipe.ts @@ -13,9 +13,11 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '$lib/shared/browser'; -import type { MotionElement } from '$lib/animate'; -import { capture, release } from './pointer-capture'; +import { isBrowser } from '../shared/browser'; +import { listen } from '../shared/listen'; +import { trackVelocity } from './velocity'; +import type { MotionElement } from '../animate'; +import { capture, lockTouchAction, release } from './pointer-capture'; export type SwipeAxis = 'x' | 'y' | 'both'; export type SwipeDirection = 'left' | 'right' | 'up' | 'down'; @@ -37,63 +39,37 @@ export interface SwipeableOptions { threshold?: number; /** Release speed that counts as a swipe regardless of distance, px/s. Default `300`. */ velocityThreshold?: number; - /** Disable without removing the attachment. */ - disabled?: boolean; onSwipe?: (direction: SwipeDirection, info: SwipeInfo, element: MotionElement) => void; } -const touchActionFor = (axis: SwipeAxis): string => - axis === 'x' ? 'pan-y' : axis === 'y' ? 'pan-x' : 'none'; - /** Create a swipeable attachment. */ export const swipeable = (options: SwipeableOptions = {}): Attachment => { - const { - axis = 'both', - threshold = 30, - velocityThreshold = 300, - disabled = false, - onSwipe - } = options; + const { axis = 'both', threshold = 30, velocityThreshold = 300, onSwipe } = options; return (element) => { - if (!isBrowser() || disabled) return; + if (!isBrowser()) return; - const prevTouchAction = element.style.touchAction; - element.style.touchAction = touchActionFor(axis); + const unlockTouchAction = lockTouchAction(element, axis); let tracking = false; let pointerId = -1; let startX = 0; let startY = 0; - // Velocity from the last two pointer samples. - let lastX = 0; - let lastY = 0; - let lastT = 0; - let velX = 0; - let velY = 0; + const velocity = trackVelocity(); const onPointerDown = (event: PointerEvent): void => { if (tracking || event.button !== 0) return; tracking = true; pointerId = event.pointerId; capture(element, pointerId); - startX = lastX = event.clientX; - startY = lastY = event.clientY; - lastT = event.timeStamp; - velX = 0; - velY = 0; + startX = event.clientX; + startY = event.clientY; + velocity.reset(event); }; const onPointerMove = (event: PointerEvent): void => { if (!tracking || event.pointerId !== pointerId) return; - const dt = (event.timeStamp - lastT) / 1000; - if (dt > 0) { - velX = (event.clientX - lastX) / dt; - velY = (event.clientY - lastY) / dt; - } - lastX = event.clientX; - lastY = event.clientY; - lastT = event.timeStamp; + velocity.sample(event); }; const onPointerUp = (event: PointerEvent): void => { @@ -108,7 +84,7 @@ export const swipeable = (options: SwipeableOptions = {}): Attachment= Math.abs(dy); const distance = horizontal ? Math.abs(dx) : Math.abs(dy); - const speed = horizontal ? Math.abs(velX) : Math.abs(velY); + const speed = horizontal ? Math.abs(velocity.x) : Math.abs(velocity.y); if (distance < threshold && speed < velocityThreshold) return; const direction: SwipeDirection = horizontal @@ -120,7 +96,7 @@ export const swipeable = (options: SwipeableOptions = {}): Attachment { - element.removeEventListener('pointerdown', down); - element.removeEventListener('pointermove', move); - element.removeEventListener('pointerup', up); - element.removeEventListener('pointercancel', cancel); - element.style.touchAction = prevTouchAction; + unlisten(); + unlockTouchAction(); }; }; }; diff --git a/src/lib/gestures/velocity.ts b/src/lib/gestures/velocity.ts new file mode 100644 index 0000000..a7c1ba7 --- /dev/null +++ b/src/lib/gestures/velocity.ts @@ -0,0 +1,55 @@ +/** + * Two-sample pointer velocity tracking, shared by the gestures that hand a + * release velocity onward — `draggable` (into its settle spring) and `swipe` + * (into its velocity threshold). + * + * Two samples is deliberately the whole model: it reads the *current* flick + * rather than an average that lags behind a direction change mid-drag. + */ + +export interface VelocityTracker { + /** Horizontal velocity in px/second, from the last two samples. */ + readonly x: number; + /** Vertical velocity in px/second, from the last two samples. */ + readonly y: number; + /** Start a new gesture at this event: seed the sample point, zero velocity. */ + reset(event: PointerEvent): void; + /** Fold another pointer position into the velocity estimate. */ + sample(event: PointerEvent): void; +} + +/** Create a velocity tracker; `reset()` on pointerdown, `sample()` on pointermove. */ +export const trackVelocity = (): VelocityTracker => { + let lastX = 0; + let lastY = 0; + let lastT = 0; + let velX = 0; + let velY = 0; + + return { + get x() { + return velX; + }, + get y() { + return velY; + }, + reset(event) { + lastX = event.clientX; + lastY = event.clientY; + lastT = event.timeStamp; + velX = 0; + velY = 0; + }, + sample(event) { + // A zero (or backwards) dt would divide by zero; keep the last estimate. + const dt = (event.timeStamp - lastT) / 1000; + if (dt > 0) { + velX = (event.clientX - lastX) / dt; + velY = (event.clientY - lastY) / dt; + } + lastX = event.clientX; + lastY = event.clientY; + lastT = event.timeStamp; + } + }; +}; diff --git a/src/lib/gestures/wheel.ts b/src/lib/gestures/wheel.ts index 17b3185..0a55df9 100644 --- a/src/lib/gestures/wheel.ts +++ b/src/lib/gestures/wheel.ts @@ -18,14 +18,13 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '$lib/shared/browser'; -import type { MotionElement } from '$lib/animate'; -import { createSpringValue } from '$lib/animate/spring-value'; -import type { SpringOptions } from '$lib/shared/types'; -import { - ensurePropertiesRegistered, - ensureTransformWired -} from '$lib/animate/properties/transform-setup'; +import { isBrowser } from '../shared/browser'; +import { listen } from '../shared/listen'; +import { applyConstraint, type AxisBounds } from './constraints'; +import type { MotionElement } from '../animate'; +import { createSpringValue } from '../animate/spring-value'; +import type { SpringOptions } from '../shared/types'; +import { wireTransform } from '../animate/properties/transform-setup'; export interface WheelInfo { /** Accumulated scale since the element mounted (baseline 1). */ @@ -41,7 +40,7 @@ export interface WheelableOptions { /** Write `--motion-scale` on each tick. Default `true`. */ applyTransform?: boolean; /** Clamp the reported (and applied) scale. */ - scaleBounds?: { min?: number; max?: number }; + scaleBounds?: AxisBounds; /** Zoom sensitivity per wheel unit. Default `0.01`. */ speed?: number; /** @@ -59,8 +58,6 @@ export interface WheelableOptions { preventDefault?: boolean; /** Idle time before the gesture is considered ended, ms. Default `120`. */ endDelay?: number; - /** Disable without removing the attachment. */ - disabled?: boolean; onStart?: (info: WheelInfo, element: MotionElement) => void; onMove?: (info: WheelInfo, element: MotionElement) => void; onEnd?: (info: WheelInfo, element: MotionElement) => void; @@ -69,14 +66,6 @@ export interface WheelableOptions { /** Snappy, overshoot-free spring tuned for zoom — near-critical damping. */ const SMOOTH_DEFAULTS: SpringOptions = { stiffness: 260, damping: 32 }; -const clampScale = (scale: number, bounds?: { min?: number; max?: number }): number => { - if (!bounds) return scale; - let next = scale; - if (bounds.min !== undefined) next = Math.max(bounds.min, next); - if (bounds.max !== undefined) next = Math.min(bounds.max, next); - return next; -}; - /** Create a wheelable (wheel-zoom) attachment. */ export const wheelable = (options: WheelableOptions = {}): Attachment => { const { @@ -86,7 +75,6 @@ export const wheelable = (options: WheelableOptions = {}): Attachment { - if (!isBrowser() || disabled) return; + if (!isBrowser()) return; // `scale` is the logical target (what callbacks report); the spring // carries the rendered value smoothly toward it across frames. @@ -117,8 +105,7 @@ export const wheelable = (options: WheelableOptions = {}): Attachment void) | null = null; if (applyTransform) { - ensurePropertiesRegistered(); - ensureTransformWired(element); + wireTransform(element); detachSpring = springScale?.subscribe((v) => element.style.setProperty('--motion-scale', `${v}`)) ?? null; } @@ -142,7 +129,7 @@ export const wheelable = (options: WheelableOptions = {}): Attachment { if (endTimer != null) clearTimeout(endTimer); - element.removeEventListener('wheel', wheel); + unlisten(); detachSpring?.(); springScale?.stop(); }; diff --git a/src/lib/gradient/README.md b/src/lib/gradient/README.md deleted file mode 100644 index 155b3dd..0000000 --- a/src/lib/gradient/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# gradient - -Tween between CSS `linear-gradient` backgrounds — something WAAPI cannot do natively (`background-image` is not interpolable). - -| File | Responsibility | -| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gradient.ts` | `animateGradient(element, from, to, options)` — runs its own rAF loop: parses both gradients, resolves colour keywords through a canvas (so `"rebeccapurple"`, `hsl(...)`, resolved `currentColor`, etc. all work), and writes an interpolated gradient string each frame. Returns `{ finished, cancel }`. | -| `parse.ts` | Pure, DOM-free gradient parsing and interpolation (`parseLinearGradient`, `parseRGBA`, `lerpRGBA`, `formatLinearGradient`, `splitTopLevel`, `resolvePositions`). Unit-tested. | - -```ts -import { animateGradient } from '@ixirjs/pulse/gradient'; - -animateGradient( - el, - 'linear-gradient(90deg, #f00 0%, #00f 100%)', - 'linear-gradient(180deg, #0f0 0%, #ff0 100%)', - { duration: 600 } -); -``` - -### Options - -| Option | Default | Description | -| ------------ | -------------------- | ----------------------------------------------------- | -| `duration` | `400` | Tween length in ms. | -| `easing` | ease-out | Easing function. | -| `delay` | `0` | Delay before starting (ms). | -| `property` | `'background-image'` | CSS property to write (e.g. `'border-image-source'`). | -| `onComplete` | — | Fired once when the tween settles. | - -> **Stop-for-stop interpolation.** Best results when both gradients share the same number of colour stops; angle, each stop's colour (RGBA) and position are interpolated independently. When the stop counts differ they can't be matched one-to-one, so the tween snaps to the target. Colour keywords are resolved to RGBA before interpolation, so any valid CSS colour works on either end. diff --git a/src/lib/gradient/gradient.ts b/src/lib/gradient/gradient.ts index 98fb133..229a072 100644 --- a/src/lib/gradient/gradient.ts +++ b/src/lib/gradient/gradient.ts @@ -7,6 +7,13 @@ * an interpolated gradient string each frame. Best results when both gradients * share the same number of colour stops; otherwise it snaps to the target. * + * ponytail: main-thread rAF loop + hand-rolled colour math. Registered + * `@property` custom properties (`` / `` / ``) would + * let the browser interpolate the stops and drop `lerpRGBA` + the canvas + * keyword-resolution hack — at the cost of a permanent global property + * registry and a Firefox 128 / Safari 16.4 floor. Switch when that floor is + * acceptable; the parser is needed either way. + * * @example * ```ts * animateGradient( @@ -18,13 +25,12 @@ * ``` */ -import { isBrowser } from '$lib/shared/browser'; -import { easeOut } from '$lib/easing'; -import type { EasingFn } from '$lib/shared/types'; +import { isBrowser } from '../shared/browser'; +import { frameTween } from '../shared/frame-tween'; +import { easeOut } from '../easing'; +import type { EasingFn } from '../shared/types'; import { - formatLinearGradient, - lerp, - lerpRGBA, + formatInterpolatedLinearGradient, parseLinearGradient, parseRGBA, resolvePositions, @@ -94,13 +100,7 @@ export const animateGradient = ( onComplete } = options; - let resolveFinished!: () => void; - const finished = new Promise((res) => (resolveFinished = res)); - - if (!isBrowser()) { - resolveFinished(); - return { finished, cancel: () => {} }; - } + if (!isBrowser()) return { finished: Promise.resolve(), cancel: () => {} }; const a = resolve(from); const b = resolve(to); @@ -110,53 +110,27 @@ export const animateGradient = ( if (a.colors.length !== b.colors.length) { write(to); onComplete?.(); - resolveFinished(); - return { finished, cancel: () => {} }; + return { finished: Promise.resolve(), cancel: () => {} }; } - let frame: number | null = null; - let startTime = 0; - let done = false; - - const finish = (): void => { - if (done) return; - done = true; - if (frame != null) cancelAnimationFrame(frame); - frame = null; - resolveFinished(); - }; - - const render = (t: number): void => { - const e = easing(t); - const stops = a.colors.map((color, i) => ({ - rgba: lerpRGBA(color, b.colors[i]!, e), - pos: lerp(a.positions[i]!, b.positions[i]!, e) - })); - write(formatLinearGradient(lerp(a.angle, b.angle, e), stops)); - }; - - const tick = (now: number): void => { - if (!startTime) startTime = now + delay; - const elapsed = now - startTime; - if (elapsed < 0) { - frame = requestAnimationFrame(tick); - return; - } - const t = duration <= 0 ? 1 : Math.min(elapsed / duration, 1); - render(t); - if (t >= 1) { - onComplete?.(); - finish(); - } else { - frame = requestAnimationFrame(tick); - } - }; - - render(0); - frame = requestAnimationFrame(tick); + const tween = frameTween({ + duration, + delay, + renderInitial: true, + onFrame: (progress) => + write( + formatInterpolatedLinearGradient( + a.angle, + b.angle, + a.colors, + b.colors, + a.positions, + b.positions, + easing(progress) + ) + ), + onComplete + }); - return { - finished, - cancel: finish - }; + return { finished: tween.finished, cancel: tween.cancel }; }; diff --git a/src/lib/gradient/parse.test.ts b/src/lib/gradient/parse.test.ts index 8ddfe79..7c23c06 100644 --- a/src/lib/gradient/parse.test.ts +++ b/src/lib/gradient/parse.test.ts @@ -4,12 +4,12 @@ import { describe, expect, it } from 'vitest'; import { + formatInterpolatedLinearGradient, formatLinearGradient, lerpRGBA, parseLinearGradient, parseRGBA, - resolvePositions, - splitTopLevel + resolvePositions } from './parse'; describe('parseRGBA()', () => { @@ -34,9 +34,17 @@ describe('lerpRGBA()', () => { }); }); -describe('splitTopLevel()', () => { +describe('stop splitting', () => { it('ignores commas nested in parentheses', () => { - expect(splitTopLevel('a, rgb(1, 2, 3), b')).toEqual(['a', 'rgb(1, 2, 3)', 'b']); + // A functional color contains its own commas — splitting on them naively + // would report four stops instead of two. + const g = parseLinearGradient( + 'linear-gradient(90deg, rgb(1, 2, 3) 0%, rgba(4, 5, 6, 0.5) 100%)' + ); + expect(g.stops).toEqual([ + { color: 'rgb(1, 2, 3)', pos: 0 }, + { color: 'rgba(4, 5, 6, 0.5)', pos: 100 } + ]); }); }); @@ -81,3 +89,16 @@ describe('formatLinearGradient()', () => { expect(css).toBe('linear-gradient(90deg, rgba(255, 0, 0, 1) 0%, rgba(0, 0, 255, 1) 100%)'); }); }); + +describe('formatInterpolatedLinearGradient()', () => { + it('matches the existing formatter’s interpolated output without stop objects', () => { + const from = [[0, 0, 0, 0] as const, [255, 0, 0, 1] as const]; + const to = [[255, 255, 255, 1] as const, [0, 0, 255, 0.5] as const]; + expect(formatInterpolatedLinearGradient(0, 90, from, to, [0, 20], [10, 100], 0.5)).toBe( + formatLinearGradient(45, [ + { rgba: lerpRGBA(from[0], to[0], 0.5), pos: 5 }, + { rgba: lerpRGBA(from[1], to[1], 0.5), pos: 60 } + ]) + ); + }); +}); diff --git a/src/lib/gradient/parse.ts b/src/lib/gradient/parse.ts index f02cc35..f2999a5 100644 --- a/src/lib/gradient/parse.ts +++ b/src/lib/gradient/parse.ts @@ -4,6 +4,8 @@ * driver layer, which feeds already-resolved `rgb/rgba/#hex` colours here. */ +import { lerp } from '../shared/math'; + export type RGBA = readonly [r: number, g: number, b: number, a: number]; export interface GradientStop { @@ -18,9 +20,6 @@ export interface LinearGradient { stops: GradientStop[]; } -/** Linear interpolation. */ -export const lerp = (a: number, b: number, t: number): number => a + (b - a) * t; - /** Interpolate two RGBA colours channel-wise (alpha included). */ export const lerpRGBA = (a: RGBA, b: RGBA, t: number): RGBA => [ Math.round(lerp(a[0], b[0], t)), @@ -59,11 +58,11 @@ export const parseRGBA = (input: string): RGBA => { }; /** Format an RGBA tuple as a CSS `rgba(...)` string. */ -export const formatRGBA = ([r, g, b, a]: RGBA): string => +const formatRGBA = ([r, g, b, a]: RGBA): string => `rgba(${r}, ${g}, ${b}, ${Number(a.toFixed(3))})`; /** Split a comma-separated list, ignoring commas nested in parentheses. */ -export const splitTopLevel = (input: string): string[] => { +const splitTopLevel = (input: string): string[] => { const out: string[] = []; let depth = 0; let start = 0; @@ -133,6 +132,38 @@ export const formatLinearGradient = (angle: number, stops: { rgba: RGBA; pos: nu .map((s) => `${formatRGBA(s.rgba)} ${Number(s.pos.toFixed(2))}%`) .join(', ')})`; +/** + * Format an interpolated gradient directly from two resolved stop lists. + * + * The rAF driver calls this every frame. Keeping interpolation and formatting + * together avoids allocating a tuple and object for each stop before creating + * the unavoidable CSS string, while preserving `formatLinearGradient()`'s + * rounding and output format. + */ +export const formatInterpolatedLinearGradient = ( + fromAngle: number, + toAngle: number, + fromColors: readonly RGBA[], + toColors: readonly RGBA[], + fromPositions: readonly number[], + toPositions: readonly number[], + t: number +): string => { + let stops = ''; + for (let i = 0; i < fromColors.length; i++) { + const from = fromColors[i]!; + const to = toColors[i]!; + const r = Math.round(lerp(from[0], to[0], t)); + const g = Math.round(lerp(from[1], to[1], t)); + const b = Math.round(lerp(from[2], to[2], t)); + const a = Number(lerp(from[3], to[3], t).toFixed(3)); + const pos = Number(lerp(fromPositions[i]!, toPositions[i]!, t).toFixed(2)); + if (i > 0) stops += ', '; + stops += `rgba(${r}, ${g}, ${b}, ${a}) ${pos}%`; + } + return `linear-gradient(${Number(lerp(fromAngle, toAngle, t).toFixed(2))}deg, ${stops})`; +}; + /** Distribute `null` stop positions evenly across `[0, 100]`. */ export const resolvePositions = (stops: GradientStop[]): number[] => stops.map((s, i) => diff --git a/src/lib/index.ts b/src/lib/index.ts index d42af15..c5a4982 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -11,7 +11,9 @@ */ export * from './flip'; export * from './animate'; -export * from './scroll'; +export * from './easing'; +// Explicit index avoids relying on directory-import resolution in consuming Vite projects. +export * from './scroll/index'; export * from './gestures'; export * from './presence'; export * from './variants'; diff --git a/src/lib/morph/README.md b/src/lib/morph/README.md deleted file mode 100644 index d9e0d52..0000000 --- a/src/lib/morph/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# morph - -SVG path morphing — tween an SVG ``'s `d` between two shapes. WAAPI can't interpolate `d` across arbitrary command lists, so this normalizes both paths to a common cubic-bézier form, aligns them, and runs its own rAF loop. - -| File | Responsibility | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `parse.ts` | `parsePath(d)` — character-scanning tokenizer that expands implicit repeats and correctly reads arc flags and scientific notation. | -| `normalize.ts` | `normalizePath(d)` — converts every command (`M L H V C S Q T A Z`, relative or absolute) into absolute cubic-bézier subpaths. Lines become thirds-placed cubics, quadratics are elevated, arcs are split into ≤90° cubic segments. | -| `interpolate.ts` | `planMorph(from, to)` aligns the two paths subpath-by-subpath, growing the one with fewer segments by De Casteljau subdivision of its longest segments, then (via `minimizeAnchorTravel`) rotates/reverses each closed ring to the shortest-travel correspondence. `interpolatePlan(plan, t)` lerps every control point and serializes back to a `d` string. | -| `morph.ts` | `morph(element, from, to, options)` — the rAF driver. Writes the interpolated `d` each frame; honors `prefers-reduced-motion`. | - -```ts -import { morph } from '@ixirjs/pulse/morph'; - -const heart = - 'M12 21s-7-4.35-9.5-8.5C1 9 3 5 6.5 5 9 5 12 8 12 8s3-3 5.5-3C21 5 23 9 21.5 12.5 19 16.65 12 21 12 21z'; -const star = 'M12 2l3 7h7l-5.5 4 2 7L12 16l-6.5 4 2-7L2 9h7z'; - -morph(pathEl, heart, star, { duration: 600 }); -``` - -### Options - -| Option | Default | Description | -| ---------------------- | -------- | ----------------------------------------------------------------- | -| `duration` | `400` | Tween length in ms. | -| `easing` | ease-out | Easing function. | -| `delay` | `0` | Delay before starting (ms). | -| `respectReducedMotion` | `true` | Snap to the target when reduced motion is requested. | -| `optimize` | `true` | Rotate/reverse closed rings to the minimal-travel correspondence. | -| `onComplete` | — | Fired once when the morph settles. | - -> **Structure alignment.** Within matching subpaths, differing segment counts are reconciled automatically by subdivision, so two shapes with different point counts still morph point-for-point. When the two paths have a **different number of subpaths** the morph can't pair them up and snaps to the target (same fallback philosophy as [`animateGradient`](../gradient/README.md)). -> -> **Minimal-distance anchor alignment** (`optimize`, on by default). For each closed subpath, the `from` ring is cyclically rotated — and reversed if its winding is opposite — to the orientation that minimizes total anchor travel against the `to` ring. This prevents the visible "twist" that happens when point 0 of one shape is paired with a far-away point on the other, so anchors take the shortest path. Open subpaths are left as-is (their start/end are fixed and must not rotate). Set `optimize: false` to pair anchors strictly in document order. diff --git a/src/lib/morph/index.ts b/src/lib/morph/index.ts index afd140b..70d3e42 100644 --- a/src/lib/morph/index.ts +++ b/src/lib/morph/index.ts @@ -9,14 +9,3 @@ export { parsePath } from './parse'; export type { PathCommand } from './parse'; export { normalizePath } from './normalize'; export type { Subpath, CubicSegment, Point } from './normalize'; -export { - planMorph, - interpolatePlan, - toPathString, - subdivideTo, - alignSubpaths, - minimizeAnchorTravel, - rotateClosed, - reverseClosed -} from './interpolate'; -export type { MorphPlan } from './interpolate'; diff --git a/src/lib/morph/interpolate.test.ts b/src/lib/morph/interpolate.test.ts index aac5a07..7fa82da 100644 --- a/src/lib/morph/interpolate.test.ts +++ b/src/lib/morph/interpolate.test.ts @@ -1,43 +1,41 @@ /** * Pure path alignment + interpolation. Runs in the `server` (node) project. + * + * The alignment internals (subdivision, ring rotation/reversal, anchor-travel + * minimization) are exercised through `planMorph` / `interpolatePlan`, the two + * entry points `morph()` itself uses — `interpolatePlan(plan, 0)` and + * `(plan, 1)` render the plan's from- and to-paths. */ import { describe, expect, it } from 'vitest'; -import { normalizePath } from './normalize'; -import { - alignSubpaths, - interpolatePlan, - minimizeAnchorTravel, - planMorph, - reverseClosed, - rotateClosed, - subdivideTo, - toPathString -} from './interpolate'; - -describe('subdivideTo()', () => { - it('grows the segment count while preserving endpoints', () => { - const [line] = normalizePath('M0 0 L10 0'); - const grown = subdivideTo(line!, 2); - expect(grown.segments).toHaveLength(2); - // Midpoint of a straight line subdivided at 0.5. - expect(grown.segments[0]!.end[0]).toBeCloseTo(5); - expect(grown.segments.at(-1)!.end).toEqual([10, 0]); +import { interpolatePlan, planMorph } from './interpolate'; + +/** Rendered endpoints of a plan — what the element's `d` shows at t=0 and t=1. */ +const ends = (from: string, to: string, optimize = true) => { + const plan = planMorph(from, to, optimize); + return { plan, start: interpolatePlan(plan, 0), end: interpolatePlan(plan, 1) }; +}; + +describe('subpath alignment', () => { + it('grows the shorter side to the larger segment count, preserving endpoints', () => { + // 1 segment vs 2 segments — both sides must end up at 2. + const { plan } = ends('M0 0 L10 0', 'M0 0 C0 0 5 5 5 5 C5 5 8 8 10 10'); + expect(plan.from[0]!.segments).toHaveLength(2); + expect(plan.to[0]!.segments).toHaveLength(2); + // Subdivision must not move the path's real endpoints. + expect(plan.from[0]!.start).toEqual([0, 0]); + expect(plan.from[0]!.segments.at(-1)!.end).toEqual([10, 0]); }); - it('is a no-op when already at the target count', () => { - const [line] = normalizePath('M0 0 L10 0'); - expect(subdivideTo(line!, 1).segments).toHaveLength(1); + it('splits a straight line at its midpoint', () => { + const { plan } = ends('M0 0 L10 0', 'M0 0 C0 0 5 5 5 5 C5 5 8 8 10 10'); + expect(plan.from[0]!.segments[0]!.end[0]).toBeCloseTo(5); }); -}); -describe('alignSubpaths()', () => { - it('brings both subpaths to the larger segment count', () => { - const [a] = normalizePath('M0 0 L10 0'); // 1 segment - const [b] = normalizePath('M0 0 C0 0 5 5 5 5 C5 5 8 8 10 10'); // 2 segments - const [aa, bb] = alignSubpaths(a!, b!); - expect(aa.segments).toHaveLength(2); - expect(bb.segments).toHaveLength(2); + it('leaves equal segment counts untouched', () => { + const { plan } = ends('M0 0 L10 0', 'M0 0 L0 10'); + expect(plan.from[0]!.segments).toHaveLength(1); + expect(plan.to[0]!.segments).toHaveLength(1); }); }); @@ -56,60 +54,42 @@ describe('planMorph()', () => { }); describe('interpolatePlan()', () => { - it('returns the from-path at t=0 and the to-path at t=1', () => { - const plan = planMorph('M0 0 L10 0', 'M0 0 L0 10'); - expect(interpolatePlan(plan, 0)).toBe(toPathString(plan.from)); - expect(interpolatePlan(plan, 1)).toBe(toPathString(plan.to)); - }); - it('lerps anchor positions at the midpoint', () => { const plan = planMorph('M0 0 L10 0', 'M10 10 L20 10'); expect(interpolatePlan(plan, 0.5).startsWith('M5 5C')).toBe(true); }); -}); - -describe('rotateClosed() / reverseClosed()', () => { - const [square] = normalizePath('M0 0 L10 0 L10 10 L0 10 Z'); - - it('rotation keeps the shape but moves the start anchor', () => { - const rotated = rotateClosed(square!, 1); - expect(rotated.start).toEqual([10, 0]); // second corner becomes the start - expect(rotated.segments).toHaveLength(square!.segments.length); - }); - - it('rotation by 0 (or a full turn) is a no-op', () => { - expect(rotateClosed(square!, 0)).toBe(square); - expect(rotateClosed(square!, square!.segments.length).start).toEqual(square!.start); - }); - it('reversal keeps the start anchor but flips winding', () => { - const reversed = reverseClosed(square!); - expect(reversed.start).toEqual([0, 0]); - // First step now heads to the previous *last* corner (0,10) instead of (10,0). - expect(reversed.segments[0]!.end).toEqual([0, 10]); + it('is monotonic between its endpoints', () => { + const { start, end } = ends('M0 0 L10 0', 'M10 10 L20 10'); + expect(start).not.toBe(end); + expect(start.startsWith('M0 0')).toBe(true); + expect(end.startsWith('M10 10')).toBe(true); }); }); -describe('minimizeAnchorTravel()', () => { +describe('anchor-travel minimization', () => { it('rotates the from-ring so an identical, differently-started shape coincides', () => { - // Same square, started at a different corner. - const plan = planMorph('M0 0 L10 0 L10 10 L0 10 Z', 'M10 0 L10 10 L0 10 L0 0 Z'); - expect(toPathString(plan.from)).toBe(toPathString(plan.to)); + // Same square, started at a different corner — rotating the ring makes the + // two rings identical, so nothing should visually move across the morph. + const { start, end } = ends('M0 0 L10 0 L10 10 L0 10 Z', 'M10 0 L10 10 L0 10 L0 0 Z'); + expect(start).toBe(end); }); it('reverses the from-ring when the target has the opposite winding', () => { - const plan = planMorph('M0 0 L10 0 L10 10 L0 10 Z', 'M0 0 L0 10 L10 10 L10 0 Z'); - expect(toPathString(plan.from)).toBe(toPathString(plan.to)); + const { start, end } = ends('M0 0 L10 0 L10 10 L0 10 Z', 'M0 0 L0 10 L10 10 L10 0 Z'); + expect(start).toBe(end); }); it('can be disabled, leaving anchors paired in document order', () => { - const unoptimized = planMorph('M0 0 L10 0 L10 10 L0 10 Z', 'M10 0 L10 10 L0 10 L0 0 Z', false); - expect(toPathString(unoptimized.from)).not.toBe(toPathString(unoptimized.to)); + const { start, end } = ends('M0 0 L10 0 L10 10 L0 10 Z', 'M10 0 L10 10 L0 10 L0 0 Z', false); + expect(start).not.toBe(end); }); - it('leaves open paths untouched (start/end are fixed)', () => { - const [open] = normalizePath('M0 0 L10 0'); - const [target] = normalizePath('M0 0 L0 10'); - expect(minimizeAnchorTravel(open!, target!)).toBe(open); + it('leaves open paths untouched — their start/end are fixed', () => { + // An open path may not be rotated or reversed, so the from-ring must still + // begin where it was authored. + const { plan } = ends('M0 0 L10 0', 'M0 10 L0 0'); + expect(plan.from[0]!.start).toEqual([0, 0]); + expect(plan.from[0]!.segments.at(-1)!.end).toEqual([10, 0]); }); }); diff --git a/src/lib/morph/interpolate.ts b/src/lib/morph/interpolate.ts index aef1e43..006c6c5 100644 --- a/src/lib/morph/interpolate.ts +++ b/src/lib/morph/interpolate.ts @@ -8,6 +8,7 @@ * DOM-free and pure. */ +import { lerp } from '../shared/math'; import { normalizePath, type CubicSegment, type Point, type Subpath } from './normalize'; const lerpPoint = (a: Point, b: Point, t: number): Point => [ @@ -40,7 +41,7 @@ const segmentStarts = (subpath: Subpath): Point[] => { }; /** Grow a subpath to exactly `count` segments by subdividing the longest ones. */ -export const subdivideTo = (subpath: Subpath, count: number): Subpath => { +const subdivideTo = (subpath: Subpath, count: number): Subpath => { const starts = segmentStarts(subpath); const items = subpath.segments.map((seg, i) => ({ start: starts[i]!, seg })); @@ -66,7 +67,7 @@ export const subdivideTo = (subpath: Subpath, count: number): Subpath => { }; /** Make two subpaths share an equal segment count. */ -export const alignSubpaths = (a: Subpath, b: Subpath): [Subpath, Subpath] => { +const alignSubpaths = (a: Subpath, b: Subpath): [Subpath, Subpath] => { if (a.segments.length < b.segments.length) return [subdivideTo(a, b.segments.length), b]; if (b.segments.length < a.segments.length) return [a, subdivideTo(b, a.segments.length)]; return [a, b]; @@ -90,7 +91,7 @@ const anchorsOf = (sp: Subpath): Point[] => { * is geometrically identical — only the starting anchor (and therefore the * point-correspondence with another path) changes. */ -export const rotateClosed = (sp: Subpath, k: number): Subpath => { +const rotateClosed = (sp: Subpath, k: number): Subpath => { const n = sp.segments.length; const r = ((k % n) + n) % n; if (r === 0) return sp; @@ -102,7 +103,7 @@ export const rotateClosed = (sp: Subpath, k: number): Subpath => { }; /** Reverse a closed subpath's winding (same start anchor, opposite direction). */ -export const reverseClosed = (sp: Subpath): Subpath => { +const reverseClosed = (sp: Subpath): Subpath => { const n = sp.segments.length; const anchors = anchorsOf(sp); const segments: CubicSegment[] = []; @@ -120,7 +121,7 @@ export const reverseClosed = (sp: Subpath): Subpath => { * rings of equal segment count; open paths are returned unchanged (their start * and end are fixed and must not rotate). */ -export const minimizeAnchorTravel = (from: Subpath, to: Subpath): Subpath => { +const minimizeAnchorTravel = (from: Subpath, to: Subpath): Subpath => { const n = from.segments.length; if (!from.closed || !to.closed || n < 2 || to.segments.length !== n) return from; @@ -142,38 +143,11 @@ export const minimizeAnchorTravel = (from: Subpath, to: Subpath): Subpath => { return best; }; -const interpolateSubpath = (a: Subpath, b: Subpath, t: number): Subpath => ({ - start: lerpPoint(a.start, b.start, t), - segments: a.segments.map((sa, i) => { - const sb = b.segments[i]!; - return { - c1: lerpPoint(sa.c1, sb.c1, t), - c2: lerpPoint(sa.c2, sb.c2, t), - end: lerpPoint(sa.end, sb.end, t) - }; - }), - closed: t < 0.5 ? a.closed : b.closed -}); - const num = (n: number): string => { const r = Math.round(n * 1000) / 1000; return Object.is(r, -0) ? '0' : String(r); }; -const point = (p: Point): string => `${num(p[0])} ${num(p[1])}`; - -/** Serialize normalized cubic subpaths back into a path `d` string. */ -export const toPathString = (subpaths: Subpath[]): string => - subpaths - .map((sp) => { - const head = `M${point(sp.start)}`; - const body = sp.segments - .map((s) => `C${point(s.c1)} ${point(s.c2)} ${point(s.end)}`) - .join(''); - return head + body + (sp.closed ? 'Z' : ''); - }) - .join(''); - export interface MorphPlan { /** Whether the two paths share a structure that can be morphed point-for-point. */ compatible: boolean; @@ -207,5 +181,21 @@ export const planMorph = (from: string, to: string, optimize = true): MorphPlan }; /** Interpolate a prepared, aligned plan at progress `t` into a `d` string. */ -export const interpolatePlan = (plan: MorphPlan, t: number): string => - toPathString(plan.from.map((sp, i) => interpolateSubpath(sp, plan.to[i]!, t))); +export const interpolatePlan = (plan: MorphPlan, t: number): string => { + let path = ''; + for (let i = 0; i < plan.from.length; i++) { + const from = plan.from[i]!; + const to = plan.to[i]!; + path += `M${num(lerp(from.start[0], to.start[0], t))} ${num(lerp(from.start[1], to.start[1], t))}`; + for (let j = 0; j < from.segments.length; j++) { + const a = from.segments[j]!; + const b = to.segments[j]!; + path += + `C${num(lerp(a.c1[0], b.c1[0], t))} ${num(lerp(a.c1[1], b.c1[1], t))}` + + ` ${num(lerp(a.c2[0], b.c2[0], t))} ${num(lerp(a.c2[1], b.c2[1], t))}` + + ` ${num(lerp(a.end[0], b.end[0], t))} ${num(lerp(a.end[1], b.end[1], t))}`; + } + if (t < 0.5 ? from.closed : to.closed) path += 'Z'; + } + return path; +}; diff --git a/src/lib/morph/morph.svelte.test.ts b/src/lib/morph/morph.svelte.test.ts index 7adcd88..6a6fdb3 100644 --- a/src/lib/morph/morph.svelte.test.ts +++ b/src/lib/morph/morph.svelte.test.ts @@ -5,8 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { morph } from './morph'; -import { normalizePath } from './normalize'; -import { toPathString } from './interpolate'; +import { interpolatePlan, planMorph } from './interpolate'; const SVG_NS = 'http://www.w3.org/2000/svg'; let svg: SVGSVGElement | null = null; @@ -31,7 +30,7 @@ describe('morph()', () => { const to = 'M0 0 L0 10'; const ctrl = morph(path, 'M0 0 L10 0', to, { duration: 60 }); await ctrl.finished; - expect(path.getAttribute('d')).toBe(toPathString(normalizePath(to))); + expect(path.getAttribute('d')).toBe(interpolatePlan(planMorph('M0 0 L10 0', to), 1)); }); it('writes an interpolated path on the first frame', () => { diff --git a/src/lib/morph/morph.ts b/src/lib/morph/morph.ts index 6f61ca3..0172d86 100644 --- a/src/lib/morph/morph.ts +++ b/src/lib/morph/morph.ts @@ -15,9 +15,10 @@ * ``` */ -import { isBrowser, shouldReduceMotion } from '$lib/shared/browser'; -import { easeOut } from '$lib/easing'; -import type { EasingFn } from '$lib/shared/types'; +import { isBrowser, shouldReduceMotion } from '../shared/browser'; +import { frameTween } from '../shared/frame-tween'; +import { easeOut } from '../easing'; +import type { EasingFn } from '../shared/types'; import { interpolatePlan, planMorph } from './interpolate'; export interface MorphOptions { @@ -61,56 +62,28 @@ export const morph = ( onComplete } = options; - let resolveFinished!: () => void; - const finished = new Promise((res) => (resolveFinished = res)); const setD = (d: string): void => element.setAttribute('d', d); if (!isBrowser() || shouldReduceMotion(respectReducedMotion)) { setD(to); onComplete?.(); - resolveFinished(); - return { finished, cancel: () => {} }; + return { finished: Promise.resolve(), cancel: () => {} }; } const plan = planMorph(from, to, optimize); if (!plan.compatible) { setD(to); onComplete?.(); - resolveFinished(); - return { finished, cancel: () => {} }; + return { finished: Promise.resolve(), cancel: () => {} }; } - let frame: number | null = null; - let startTime = 0; - let done = false; - - const finish = (): void => { - if (done) return; - done = true; - if (frame != null) cancelAnimationFrame(frame); - frame = null; - resolveFinished(); - }; - - const tick = (now: number): void => { - if (!startTime) startTime = now + delay; - const elapsed = now - startTime; - if (elapsed < 0) { - frame = requestAnimationFrame(tick); - return; - } - const t = duration <= 0 ? 1 : Math.min(elapsed / duration, 1); - setD(interpolatePlan(plan, easing(t))); - if (t >= 1) { - onComplete?.(); - finish(); - } else { - frame = requestAnimationFrame(tick); - } - }; - - setD(interpolatePlan(plan, 0)); - frame = requestAnimationFrame(tick); + const tween = frameTween({ + duration, + delay, + renderInitial: true, + onFrame: (progress) => setD(interpolatePlan(plan, easing(progress))), + onComplete + }); - return { finished, cancel: finish }; + return { finished: tween.finished, cancel: tween.cancel }; }; diff --git a/src/lib/presence/README.md b/src/lib/presence/README.md deleted file mode 100644 index 9eeadaa..0000000 --- a/src/lib/presence/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# presence - -Spring-powered enter/exit transitions that plug into Svelte's built-in `transition:` / `in:` / `out:` directives. - -Svelte already _orchestrates_ presence — a keyed `{#each}` runs `out:` before unmount and `in:` on mount. What it lacks is spring timing. These transitions accept a `spring` option and derive **both** the eased curve and the natural settling `duration` from the simulation, so enters and exits feel like the rest of the library. - -| File | Responsibility | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `transitions.ts` | `fade`, `fly`, `scale`, and `size` — each returns a Svelte `TransitionConfig`. `resolveTiming()` turns a `spring` option into `{ duration, easing }` via [`springEasing()`](../animate/README.md), or falls back to a duration + ease-out when no spring is given. | - -```svelte - - -{#each items as item (item.id)} -
{item.text}
-{/each} - - -{#if open} -
-{/if} - - -{#if open} -
-{/if} -``` - -### Shared params - -| Param | Description | -| ---------- | ------------------------------------------------------------------------- | -| `spring` | Spring physics for the curve + auto-sized duration. `true` uses defaults. | -| `duration` | Explicit ms; overrides the spring's natural duration. | -| `easing` | Easing function; overrides the spring curve when both are given. | -| `delay` | Delay before the transition starts (ms). | - -`fly` adds `x` / `y` / `opacity`; `scale` adds `start` / `opacity`. - -Both `fly` and `scale` also take **optional `width` / `height`** — a start size that tweens to the element's measured natural (auto-resolved) size _alongside_ the travel/pop. Omit them for transform-only behavior: - -```svelte - -
- - -
-``` - -| Param | Description | -| -------- | ----------------------------------------------------------------- | -| `width` | Start width; tweens to the natural width. Omit to leave width be. | -| `height` | Start height; tweens to the natural height. Omit to leave it be. | - -The start accepts a **px number or any CSS length** — `0`, `'2rem'`, `'50%'`, `'10vw'`, `'calc(…)'`. CSS values are resolved to px against the element in its real context (so `%` is relative to its containing block), then interpolated. The **target is always the element's natural size**, measured automatically — so `width: 0` grows to whatever `width: auto` would be, with no fixed pixel target needed. - -The collapse scales the axis's **padding, border, and margin** by the same fraction as the width/height (just like `size`), so the box reaches a true zero footprint. Without this, `box-sizing: border-box` would floor the element at its padding (`width: 0` ⇒ still padding-wide), leaving a stub and a layout slot that snaps shut on unmount. - -`size` collapses an element's **width and/or height** (with its padding, border, and margin on that axis) — the way to drive width/height on enter/exit. Like Svelte's `slide`, but either axis: - -| Param | Default | Description | -| --------- | ------- | --------------------------------------------------------------------- | -| `axis` | `'y'` | `'x'`, `'y'`, or `'both'` — which dimension(s) to collapse. | -| `start` | `0` | Fraction of the natural size to start/end at (`0` = fully collapsed). | -| `opacity` | — | Omit for size-only; pass `0` to fade alongside the collapse. | - -> These are CSS-driven transitions (they return a `css` function), so they animate `transform` / `opacity` directly and are independent of the `--motion-*` chain — appropriate for short-lived mount/unmount, where the element is not simultaneously being driven by `animate()`. diff --git a/src/lib/presence/transitions.ts b/src/lib/presence/transitions.ts index 119f2db..84b6688 100644 --- a/src/lib/presence/transitions.ts +++ b/src/lib/presence/transitions.ts @@ -17,8 +17,9 @@ */ import type { TransitionConfig } from 'svelte/transition'; -import { easeOut, springEasing } from '$lib/easing'; -import type { EasingFn, SpringOptions } from '$lib/shared/types'; +import { easeOut, springEasing } from '../easing'; +import { restoreStyleProp, saveStyleProp } from '../shared/inline-style'; +import type { EasingFn, SpringOptions } from '../shared/types'; export interface PresenceParams { /** Delay before the transition starts (ms). */ @@ -81,12 +82,10 @@ export interface SizeFields { const resolveSize = (node: Element, axis: 'width' | 'height', value: SizeValue): number => { if (typeof value === 'number') return value; const style = (node as HTMLElement).style; - const prev = style.getPropertyValue(axis); - const priority = style.getPropertyPriority(axis); + const saved = saveStyleProp(style, axis); style.setProperty(axis, value); const resolved = px(getComputedStyle(node), axis); - if (prev) style.setProperty(axis, prev, priority); - else style.removeProperty(axis); + restoreStyleProp(style, axis, saved); return resolved; }; @@ -120,26 +119,52 @@ interface AxisPlan { } /** - * Plan a width/height collapse for one axis. Scaling only `width`/`height` - * isn't enough — under `box-sizing: border-box` the box floors at its padding, - * so a `width: 0` element never fully closes its slot. We therefore collapse - * the padding, border, and margin on the axis by the same fraction, exactly - * like the `size` transition, so the footprint reaches zero. Naturals are read - * before `resolveSize()` (which momentarily overrides the inline size). + * Snapshot an axis's natural footprint: the main size plus every spacing + * contribution (padding, border, margin) on that axis. Scaling only + * `width`/`height` isn't enough — under `box-sizing: border-box` the box floors + * at its padding, so a `width: 0` element never fully closes its slot. + * Collapsing the spacing by the same fraction takes the footprint to zero. */ -const planAxis = (node: Element, axis: 'width' | 'height', value: SizeValue): AxisPlan => { +const axisMetrics = (node: Element, axis: 'width' | 'height'): Omit => { const style = getComputedStyle(node); - const natural = px(style, axis); - const spacing = AXIS_SPACING[axis].map((prop): [string, number] => [prop, px(style, prop)]); + return { + main: axis, + natural: px(style, axis), + spacing: AXIS_SPACING[axis].map((prop): [string, number] => [prop, px(style, prop)]) + }; +}; + +/** + * Plan a collapse for one axis from a caller-supplied start size. Naturals are + * read before `resolveSize()`, which momentarily overrides the inline size. + */ +const planAxis = (node: Element, axis: 'width' | 'height', value: SizeValue): AxisPlan => { + const metrics = axisMetrics(node, axis); const start = resolveSize(node, axis, value); - return { main: axis, natural, spacing, startFraction: natural > 0 ? start / natural : 0 }; + return { + ...metrics, + startFraction: metrics.natural > 0 ? start / metrics.natural : 0 + }; +}; + +/** + * Declarations that place every planned axis at eased progress `t` — the single + * emitter shared by the `width`/`height` fields and the `size` transition. + */ +const growDecls = (plans: readonly AxisPlan[], t: number): string[] => { + const decls = ['overflow: hidden']; + for (const p of plans) { + const f = p.startFraction + t * (1 - p.startFraction); + decls.push(`${p.main}: ${f * p.natural}px`); + for (const [prop, nat] of p.spacing) decls.push(`${prop}: ${f * nat}px`); + } + return decls; }; /** * Build a `(t) => declarations[]` that grows the element from the given start - * size to its natural footprint on each requested axis — width/height plus the - * axis's padding, border, and margin — so the box (and its layout slot) fully - * collapses at the low end. Returns `null` when neither dimension is requested. + * size to its natural footprint on each requested axis. Returns `null` when + * neither dimension is requested. */ const sizeTween = ( node: Element, @@ -149,15 +174,7 @@ const sizeTween = ( const plans: AxisPlan[] = []; if (width != null) plans.push(planAxis(node, 'width', width)); if (height != null) plans.push(planAxis(node, 'height', height)); - return (t) => { - const decls = ['overflow: hidden']; - for (const p of plans) { - const f = p.startFraction + t * (1 - p.startFraction); - decls.push(`${p.main}: ${f * p.natural}px`); - for (const [prop, nat] of p.spacing) decls.push(`${prop}: ${f * nat}px`); - } - return decls; - }; + return (t) => growDecls(plans, t); }; export type FadeParams = PresenceParams; @@ -228,55 +245,18 @@ export interface SizeParams extends PresenceParams { */ export const size = (node: Element, params: SizeParams = {}): TransitionConfig => { const { axis = 'y', start = 0, opacity } = params; - const style = getComputedStyle(node); - const doX = axis === 'x' || axis === 'both'; - const doY = axis === 'y' || axis === 'both'; - // Snapshot the natural box metrics once; the css() closure scales them by `f`. - const m = { - width: px(style, 'width'), - height: px(style, 'height'), - paddingLeft: px(style, 'padding-left'), - paddingRight: px(style, 'padding-right'), - paddingTop: px(style, 'padding-top'), - paddingBottom: px(style, 'padding-bottom'), - marginLeft: px(style, 'margin-left'), - marginRight: px(style, 'margin-right'), - marginTop: px(style, 'margin-top'), - marginBottom: px(style, 'margin-bottom'), - borderLeft: px(style, 'border-left-width'), - borderRight: px(style, 'border-right-width'), - borderTop: px(style, 'border-top-width'), - borderBottom: px(style, 'border-bottom-width') - }; - const baseOpacity = opacity ?? px(style, 'opacity'); + // Snapshot the natural box metrics once; growDecls() scales them by `f`. + // `start` is already a fraction here, so no resolveSize() round-trip. + const plans: AxisPlan[] = []; + if (axis === 'x' || axis === 'both') + plans.push({ ...axisMetrics(node, 'width'), startFraction: start }); + if (axis === 'y' || axis === 'both') + plans.push({ ...axisMetrics(node, 'height'), startFraction: start }); return config(params, (t) => { - const f = start + t * (1 - start); - const decls = ['overflow: hidden']; - if (doX) { - decls.push( - `width: ${f * m.width}px`, - `padding-left: ${f * m.paddingLeft}px`, - `padding-right: ${f * m.paddingRight}px`, - `margin-left: ${f * m.marginLeft}px`, - `margin-right: ${f * m.marginRight}px`, - `border-left-width: ${f * m.borderLeft}px`, - `border-right-width: ${f * m.borderRight}px` - ); - } - if (doY) { - decls.push( - `height: ${f * m.height}px`, - `padding-top: ${f * m.paddingTop}px`, - `padding-bottom: ${f * m.paddingBottom}px`, - `margin-top: ${f * m.marginTop}px`, - `margin-bottom: ${f * m.marginBottom}px`, - `border-top-width: ${f * m.borderTop}px`, - `border-bottom-width: ${f * m.borderBottom}px` - ); - } - if (opacity != null) decls.push(`opacity: ${baseOpacity + t * (1 - baseOpacity)}`); + const decls = growDecls(plans, t); + if (opacity != null) decls.push(`opacity: ${opacity + t * (1 - opacity)}`); return decls.join('; '); }); }; diff --git a/src/lib/scroll/README.md b/src/lib/scroll/README.md deleted file mode 100644 index d4aeade..0000000 --- a/src/lib/scroll/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# scroll - -Scroll-driven animation for Svelte 5. Two complementary primitives: bind an animation's _progress_ to scroll position, or _trigger_ effects when an element crosses the viewport. - -| File | Responsibility | -| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `scroll.ts` | `scroll(options)` — a Svelte attachment that drives a paused [`AnimationController`](../animate/README.md) by seeking it: progress `0…1` maps to `0…totalDuration`. Rather than depend on the still-patchy native `ScrollTimeline`, it pauses the animation and `seek()`s it on every scroll frame, so it works in every browser and composes with springs, per-prop timing, and the `--motion-*` chain. Also accepts a raw `onProgress(p)` callback. | -| `in-view.ts` | `inView(options)` — an `IntersectionObserver`-backed attachment that fires `onEnter` / `onLeave` (with `once`, `amount`, `margin`, `root`). This is the "play when in view" / `whileInView` primitive. | -| `progress.ts` | Pure, DOM-free progress math (`coverProgress`, `containProgress`, `pageProgress`) so the mapping is unit-testable without a browser. | - -```svelte - - - -
animate(el, { y: [40, -40], opacity: [0, 1] }) })}>…
- - -
animate(el, { opacity: [0, 1], y: [24, 0] }) })} -> - … -
- - -
(bar.style.scaleX = String(p)) })} /> -``` - -### Ranges - -| `range` | Maps progress across | -| ------------------- | ----------------------------------------------------------------------- | -| `'cover'` (default) | The element crossing the viewport — `0` about to enter, `1` fully gone. | -| `'contain'` | Only the span where the element is fully visible. | -| `'page'` | Whole-scroller progress; ignores the element. | - -Pass `axis: 'x'` for horizontal scrollers and `container` to track a scrollable element instead of the window. diff --git a/src/lib/scroll/in-view.ts b/src/lib/scroll/in-view.ts index c41c96b..cfae8cd 100644 --- a/src/lib/scroll/in-view.ts +++ b/src/lib/scroll/in-view.ts @@ -17,8 +17,8 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '$lib/shared/browser'; -import type { MotionElement } from '$lib/animate'; +import { isBrowser } from '../shared/browser'; +import type { MotionElement } from '../animate'; export interface InViewOptions { /** Fired when the element crosses into view. */ diff --git a/src/lib/scroll/progress.ts b/src/lib/scroll/progress.ts index 260fed3..d6cebf3 100644 --- a/src/lib/scroll/progress.ts +++ b/src/lib/scroll/progress.ts @@ -4,7 +4,7 @@ * progress value. */ -import { clamp01 } from '$lib/shared/math'; +import { clamp01 } from '../shared/math'; /** Whole-scroller progress: how far the scroll position has travelled. */ export const pageProgress = (scroll: number, scrollSize: number, viewport: number): number => diff --git a/src/lib/scroll/scroll.ts b/src/lib/scroll/scroll.ts index edd18a6..e1df33b 100644 --- a/src/lib/scroll/scroll.ts +++ b/src/lib/scroll/scroll.ts @@ -24,8 +24,10 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '$lib/shared/browser'; -import type { AnimationController, MotionElement } from '$lib/animate'; +import { isBrowser } from '../shared/browser'; +import { createFrameBatch } from '../shared/frame-batch'; +import { listen } from '../shared/listen'; +import type { AnimationController, MotionElement } from '../animate'; import { coverProgress, containProgress, pageProgress } from './progress'; export type ScrollAxis = 'x' | 'y'; @@ -134,25 +136,23 @@ export const scroll = (options: ScrollOptions = {}): Attachment = const duration = controller ? totalDuration(controller) : 0; const scrollSource: EventTarget = container ?? window; - let frame: number | null = null; const update = (): void => { - frame = null; const progress = progressFor(range, readState(element, container, axis)); controller?.seek(progress * duration); onProgress?.(progress, element); }; - const schedule = (): void => { - if (frame == null) frame = requestAnimationFrame(update); - }; + // Scroll attachments remain independently owned, but their geometry work + // shares one rAF when several receive the same scroll/resize burst. + const batch = createFrameBatch(update); update(); - scrollSource.addEventListener('scroll', schedule, { passive: true }); - window.addEventListener('resize', schedule, { passive: true }); + const unlistenScroll = listen(scrollSource, { scroll: batch.schedule }, { passive: true }); + const unlistenResize = listen(window, { resize: batch.schedule }, { passive: true }); return () => { - if (frame != null) cancelAnimationFrame(frame); - scrollSource.removeEventListener('scroll', schedule); - window.removeEventListener('resize', schedule); + batch.cancel(); + unlistenScroll(); + unlistenResize(); controller?.cancel(); }; }; diff --git a/src/lib/shared/README.md b/src/lib/shared/README.md deleted file mode 100644 index 771f1d9..0000000 --- a/src/lib/shared/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# shared - -Internal infrastructure shared across the library — core types, browser/accessibility detection, math helpers, and the spring physics engine. Nothing here is end-user-facing; it exists so [`animate`](../animate/README.md), [`flip`](../flip/README.md), and [`easing`](../easing/README.md) can share primitives without circular imports. - -## What's here - -| File | Provides | -| --- | --- | -| `types.ts` | `EasingFn` (`(t) => number`), `MotionElement` (`HTMLElement \| SVGElement`), and `SpringOptions` (`stiffness`, `damping`, `mass`, `velocity`, `restDelta`, `restSpeed`). | -| `browser.ts` | `isBrowser()`, `prefersReducedMotion()` (cached media-query check), and `shouldReduceMotion(respectFlag?)`. | -| `math.ts` | `atLeast0(n)` → `max(0, n)` and `clamp01(n)` → clamp to `[0, 1]`. | -| `spring-core.ts` | The spring simulation engine and WAAPI easing-string helpers. | - -## Spring core - -`spring-core.ts` simulates a mass-spring-damper system at 60 fps and caches the result (128-entry LRU keyed by the canonical options string), so repeated `springEasing(...)` calls with the same parameters never re-simulate. - -```ts -import { getCachedSpring, sampleAt, samplesToLinearEasing } from '$lib/shared/spring-core'; - -const { spring, linearEasingCss } = getCachedSpring({ stiffness: 200, damping: 20 }); -// spring.samples: number[] spring.duration: ms -// linearEasingCss: "linear(0.00000, 0.20000, …, 1.00000)" for WAAPI -``` - -| Export | Purpose | -| --- | --- | -| `getCachedSpring(options?)` | Simulate (or fetch cached) `{ spring: { samples, duration }, linearEasingCss }`. | -| `sampleAt(samples, t)` | Linear-interpolate normalized progress at fractional `t ∈ [0, 1]`. | -| `samplesToLinearEasing(samples)` | Convert a sample array to a WAAPI `linear(...)` easing string. | - -**Physics** — each frame (`dt = 1000/60` ms): `accel = (-stiffness·pos − damping·vel) / mass`, then integrate velocity and position. Terminates once position and velocity stay under their rest thresholds for 3 frames, capped at 10 s. Defaults: `stiffness 170`, `damping 26`, `mass 1`. diff --git a/src/lib/shared/browser.test.ts b/src/lib/shared/browser.test.ts index 894a094..a785d29 100644 --- a/src/lib/shared/browser.test.ts +++ b/src/lib/shared/browser.test.ts @@ -1,11 +1,11 @@ /** * Tests for browser environment detection utilities. * Runs in the `server` (node) vitest project where `window` is undefined, - * so isBrowser() is always false and prefersReducedMotion() always false. + * so isBrowser() is always false and reduced motion never suppresses animation. */ import { describe, expect, it } from 'vitest'; -import { isBrowser, prefersReducedMotion } from './browser'; +import { isBrowser, shouldReduceMotion } from './browser'; describe('isBrowser()', () => { it('returns false in node environment', () => { @@ -17,18 +17,16 @@ describe('isBrowser()', () => { }); }); -describe('prefersReducedMotion()', () => { +describe('shouldReduceMotion()', () => { it('returns false in node environment (no matchMedia)', () => { - expect(prefersReducedMotion()).toBe(false); + expect(shouldReduceMotion()).toBe(false); }); - it('returns a boolean', () => { - expect(typeof prefersReducedMotion()).toBe('boolean'); + it('returns false when the respect flag is off, regardless of environment', () => { + expect(shouldReduceMotion(false)).toBe(false); }); - it('calling it multiple times returns the same value', () => { - const a = prefersReducedMotion(); - const b = prefersReducedMotion(); - expect(a).toBe(b); + it('returns a boolean', () => { + expect(typeof shouldReduceMotion()).toBe('boolean'); }); }); diff --git a/src/lib/shared/browser.ts b/src/lib/shared/browser.ts index df9592a..aec69e6 100644 --- a/src/lib/shared/browser.ts +++ b/src/lib/shared/browser.ts @@ -1,19 +1,9 @@ -export const isBrowser = (): boolean => - typeof window !== 'undefined' && typeof document !== 'undefined'; - -let reducedMotionQuery: MediaQueryList | null | undefined; - -export const prefersReducedMotion = (): boolean => { - if (reducedMotionQuery === undefined) { - reducedMotionQuery = - isBrowser() && typeof window.matchMedia === 'function' - ? window.matchMedia('(prefers-reduced-motion: reduce)') - : null; - } - return reducedMotionQuery?.matches ?? false; -}; +export const isBrowser = (): boolean => typeof document !== 'undefined'; /** Returns true when reduced motion should suppress animation. * `respectFlag` defaults to `true`, matching `AnimateDefaults.respectReducedMotion`. */ export const shouldReduceMotion = (respectFlag?: boolean): boolean => - (respectFlag ?? true) && prefersReducedMotion(); + (respectFlag ?? true) && + isBrowser() && + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; diff --git a/src/lib/shared/frame-batch.test.ts b/src/lib/shared/frame-batch.test.ts new file mode 100644 index 0000000..7627f2b --- /dev/null +++ b/src/lib/shared/frame-batch.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createFrameBatch } from './frame-batch'; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('createFrameBatch()', () => { + it('coalesces independent subscribers into one browser frame', () => { + let frame: FrameRequestCallback | undefined; + const request = vi.fn((callback: FrameRequestCallback) => { + frame = callback; + return 1; + }); + vi.stubGlobal('requestAnimationFrame', request); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + const first = vi.fn(); + const second = vi.fn(); + const a = createFrameBatch(first); + const b = createFrameBatch(second); + + a.schedule(); + b.schedule(); + a.schedule(); + expect(request).toHaveBeenCalledOnce(); + + frame!(0); + expect(first).toHaveBeenCalledOnce(); + expect(second).toHaveBeenCalledOnce(); + }); + + it('removes a cancelled subscriber without cancelling other work', () => { + let frame: FrameRequestCallback | undefined; + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frame = callback; + return 7; + }); + const cancel = vi.fn(); + vi.stubGlobal('cancelAnimationFrame', cancel); + const first = vi.fn(); + const second = vi.fn(); + const a = createFrameBatch(first); + const b = createFrameBatch(second); + + a.schedule(); + b.schedule(); + a.cancel(); + frame!(0); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledOnce(); + expect(cancel).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/shared/frame-batch.ts b/src/lib/shared/frame-batch.ts new file mode 100644 index 0000000..ee1227c --- /dev/null +++ b/src/lib/shared/frame-batch.ts @@ -0,0 +1,53 @@ +/** + * Shared requestAnimationFrame batch for independently-owned visual updates. + * + * A batch preserves each subscriber's callback while ensuring a burst of + * schedules across attachments produces one browser frame request. Subscribers + * may safely remove themselves during a flush. + */ + +export interface FrameBatch { + /** Queue this subscriber once for the next animation frame. */ + schedule(): void; + /** Remove this subscriber without affecting other queued work. */ + cancel(): void; +} + +const pending = new Set<() => void>(); +let frame: number | null = null; + +const flush = (): void => { + frame = null; + const callbacks = [...pending]; + pending.clear(); + for (const callback of callbacks) callback(); +}; + +/** Create a cancellable subscriber to the shared visual-update frame. */ +export const createFrameBatch = (callback: () => void): FrameBatch => { + let queued = false; + + const schedule = (): void => { + if (queued) return; + queued = true; + pending.add(run); + if (frame == null) frame = requestAnimationFrame(flush); + }; + + const run = (): void => { + queued = false; + callback(); + }; + + const cancel = (): void => { + if (!queued) return; + queued = false; + pending.delete(run); + if (pending.size === 0 && frame != null) { + cancelAnimationFrame(frame); + frame = null; + } + }; + + return { schedule, cancel }; +}; diff --git a/src/lib/shared/frame-tween.test.ts b/src/lib/shared/frame-tween.test.ts new file mode 100644 index 0000000..8bd6f48 --- /dev/null +++ b/src/lib/shared/frame-tween.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { frameTween } from './frame-tween'; + +type Queue = Map; + +const installFrames = (): { queue: Queue; flush: (time: number) => void } => { + let id = 0; + const queue: Queue = new Map(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + queue.set(++id, callback); + return id; + }); + vi.stubGlobal('cancelAnimationFrame', (handle: number) => queue.delete(handle)); + return { + queue, + flush(time) { + const next = queue.entries().next().value as [number, FrameRequestCallback] | undefined; + if (!next) throw new Error('No frame is scheduled'); + queue.delete(next[0]); + next[1](time); + } + }; +}; + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('frameTween()', () => { + it('renders an optional initial sample and completes exactly once', async () => { + const { flush } = installFrames(); + const samples: number[] = []; + const complete = vi.fn(); + const tween = frameTween({ + duration: 100, + delay: 20, + renderInitial: true, + onFrame: (progress) => samples.push(progress), + onComplete: complete + }); + + expect(samples).toEqual([0]); + flush(100); // Establishes the delay window. + flush(120); + flush(170); + flush(220); + await tween.finished; + + expect(samples).toEqual([0, 0, 0.5, 1]); + expect(complete).toHaveBeenCalledOnce(); + }); + + it('holds progress at 0 until the delay window elapses', () => { + const { flush } = installFrames(); + const samples: number[] = []; + frameTween({ + duration: 100, + delay: 50, + onFrame: (progress) => samples.push(progress) + }); + + flush(100); // Establishes the delay window. + flush(120); + flush(150); + expect(samples).toEqual([0]); + }); + + it('cancels pending work without a terminal sample or completion callback', async () => { + const { queue } = installFrames(); + const complete = vi.fn(); + const tween = frameTween({ + duration: 100, + delay: 0, + onFrame: vi.fn(), + onComplete: complete + }); + + tween.cancel(); + tween.cancel(); + await tween.finished; + expect(queue).toHaveLength(0); + expect(complete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/shared/frame-tween.ts b/src/lib/shared/frame-tween.ts new file mode 100644 index 0000000..2c16469 --- /dev/null +++ b/src/lib/shared/frame-tween.ts @@ -0,0 +1,68 @@ +/** + * Small lifecycle owner for requestAnimationFrame-based, duration-bound work. + * + * The caller owns what each progress sample means; this module owns scheduling, + * delay, completion, cancellation, and the settled promise. Keeping those + * concerns together prevents the library's JS-driven animation helpers from + * drifting in their terminal and cancellation behavior. + */ + +export interface FrameTween { + /** Resolves when the tween reaches its terminal progress or is cancelled. */ + readonly finished: Promise; + /** Stop future frames without rendering a terminal sample or calling `onComplete`. */ + cancel(): void; +} + +interface FrameTweenOptions { + duration: number; + /** Delay in ms, counted from the first browser frame. */ + delay: number; + /** Render progress 0 synchronously before scheduling the first frame. */ + renderInitial?: boolean; + onFrame: (progress: number) => void; + onComplete?: () => void; +} + +/** Run a cancellable rAF tween while preserving the caller's timing contract. */ +export const frameTween = ({ + duration, + delay, + renderInitial = false, + onFrame, + onComplete +}: FrameTweenOptions): FrameTween => { + let frame: number | null = null; + let startTime: number | null = null; + let done = false; + let resolveFinished!: () => void; + const finished = new Promise((resolve) => (resolveFinished = resolve)); + + const finish = (completed: boolean): void => { + if (done) return; + done = true; + if (frame != null) cancelAnimationFrame(frame); + frame = null; + if (completed) onComplete?.(); + resolveFinished(); + }; + + const tick = (now: number): void => { + if (startTime == null) startTime = now + delay; + const elapsed = now - startTime; + if (elapsed < 0) { + frame = requestAnimationFrame(tick); + return; + } + + const progress = duration > 0 ? Math.min(elapsed / duration, 1) : 1; + onFrame(progress); + if (progress >= 1) finish(true); + else frame = requestAnimationFrame(tick); + }; + + if (renderInitial) onFrame(0); + frame = requestAnimationFrame(tick); + + return { finished, cancel: () => finish(false) }; +}; diff --git a/src/lib/shared/index.ts b/src/lib/shared/index.ts deleted file mode 100644 index 2405a0b..0000000 --- a/src/lib/shared/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './browser'; -export * from './math'; -export * from './types'; -export * from './spring-core'; diff --git a/src/lib/animate/properties/style-utils.test.ts b/src/lib/shared/inline-style.test.ts similarity index 96% rename from src/lib/animate/properties/style-utils.test.ts rename to src/lib/shared/inline-style.test.ts index 69faee3..6a0cd40 100644 --- a/src/lib/animate/properties/style-utils.test.ts +++ b/src/lib/shared/inline-style.test.ts @@ -5,7 +5,7 @@ */ import { describe, expect, it } from 'vitest'; -import { restoreStyleProp, saveStyleProp } from './style-utils'; +import { restoreStyleProp, saveStyleProp } from './inline-style'; /** Minimal CSSStyleDeclaration stand-in backed by a Map. */ const fakeStyle = () => { diff --git a/src/lib/shared/inline-style.ts b/src/lib/shared/inline-style.ts new file mode 100644 index 0000000..83fcbac --- /dev/null +++ b/src/lib/shared/inline-style.ts @@ -0,0 +1,28 @@ +/** + * Inline-style save / restore, for the places that force a temporary value on + * an element and must put the prior one back. + * + * Dependency-free on purpose: kept out of `prop-utils` so the static property + * registry can import it without forming a cycle. + */ + +/** A captured inline style property — its value plus `!important` priority. */ +export interface SavedStyleProp { + value: string; + priority: string; +} + +export const saveStyleProp = (style: CSSStyleDeclaration, name: string): SavedStyleProp => ({ + value: style.getPropertyValue(name), + priority: style.getPropertyPriority(name) +}); + +/** Re-apply a captured value + priority, or remove the property if it was unset. */ +export const restoreStyleProp = ( + style: CSSStyleDeclaration, + name: string, + saved: SavedStyleProp +): void => { + if (saved.value) style.setProperty(name, saved.value, saved.priority); + else style.removeProperty(name); +}; diff --git a/src/lib/shared/listen.svelte.test.ts b/src/lib/shared/listen.svelte.test.ts new file mode 100644 index 0000000..52bd989 --- /dev/null +++ b/src/lib/shared/listen.svelte.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest'; +import { listen } from './listen'; + +describe('listen()', () => { + it('attaches every handler and detaches exactly those on teardown', () => { + const target = document.createElement('div'); + const down = vi.fn(); + const up = vi.fn(); + + const unlisten = listen(target, { pointerdown: down, pointerup: up }); + target.dispatchEvent(new Event('pointerdown')); + target.dispatchEvent(new Event('pointerup')); + expect(down).toHaveBeenCalledTimes(1); + expect(up).toHaveBeenCalledTimes(1); + + unlisten(); + target.dispatchEvent(new Event('pointerdown')); + target.dispatchEvent(new Event('pointerup')); + expect(down).toHaveBeenCalledTimes(1); + expect(up).toHaveBeenCalledTimes(1); + }); + + it('forwards listener options so teardown matches the capture phase', () => { + const target = document.createElement('div'); + const child = target.appendChild(document.createElement('span')); + const handler = vi.fn(); + + const unlisten = listen(target, { click: handler }, { capture: true }); + child.dispatchEvent(new Event('click', { bubbles: true })); + expect(handler).toHaveBeenCalledTimes(1); + + unlisten(); + child.dispatchEvent(new Event('click', { bubbles: true })); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/shared/listen.ts b/src/lib/shared/listen.ts new file mode 100644 index 0000000..2270736 --- /dev/null +++ b/src/lib/shared/listen.ts @@ -0,0 +1,31 @@ +/** + * Typed `addEventListener` bundle. + * + * Every gesture attachment wires a handful of DOM events and must tear down + * exactly the same set. Doing that by hand costs a cast per handler (the + * `HTMLElementEventMap` value type never matches `EventListener`) and a + * mirrored `removeEventListener` per handler that can silently drift from the + * add side. `listen()` owns both halves: pass a map of handlers, get the + * teardown back. + */ + +/** Handlers keyed by DOM event name, each typed to its own event. */ +export type EventHandlers = { + [K in keyof HTMLElementEventMap]?: (event: HTMLElementEventMap[K]) => void; +}; + +/** + * Attach every handler in `handlers` to `target`, and return the function that + * detaches exactly those handlers. + */ +export const listen = ( + target: EventTarget, + handlers: EventHandlers, + options?: AddEventListenerOptions +): (() => void) => { + const entries = Object.entries(handlers) as [string, EventListener][]; + for (const [type, handler] of entries) target.addEventListener(type, handler, options); + return () => { + for (const [type, handler] of entries) target.removeEventListener(type, handler, options); + }; +}; diff --git a/src/lib/shared/math.test.ts b/src/lib/shared/math.test.ts deleted file mode 100644 index 2720b3e..0000000 --- a/src/lib/shared/math.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Tests for the shared numeric clamp helpers. - * No DOM access — runs in the `server` vitest project. - */ - -import { describe, expect, it } from 'vitest'; -import { atLeast0, clamp01 } from './math'; - -describe('atLeast0()', () => { - it('passes through positive values', () => { - expect(atLeast0(5)).toBe(5); - expect(atLeast0(0.001)).toBe(0.001); - }); - - it('clamps negatives to 0', () => { - expect(atLeast0(-1)).toBe(0); - expect(atLeast0(-0.0001)).toBe(0); - }); - - it('returns 0 unchanged', () => { - expect(atLeast0(0)).toBe(0); - }); -}); - -describe('clamp01()', () => { - it('passes through values within [0, 1]', () => { - expect(clamp01(0)).toBe(0); - expect(clamp01(0.5)).toBe(0.5); - expect(clamp01(1)).toBe(1); - }); - - it('clamps values above 1 to 1', () => { - expect(clamp01(1.5)).toBe(1); - expect(clamp01(100)).toBe(1); - }); - - it('clamps values below 0 to 0', () => { - expect(clamp01(-0.5)).toBe(0); - expect(clamp01(-100)).toBe(0); - }); -}); diff --git a/src/lib/shared/math.ts b/src/lib/shared/math.ts index f854dd3..be538e9 100644 --- a/src/lib/shared/math.ts +++ b/src/lib/shared/math.ts @@ -1,10 +1,7 @@ -/** - * Tiny numeric clamps shared across the animation modules so the same - * bound-checking lives in one place instead of being re-spelled inline. - */ +/** Scalar helpers shared across the animation, scroll and interpolation code. */ -/** Clamp `n` to be non-negative (≥ 0). */ -export const atLeast0 = (n: number): number => Math.max(0, n); +/** Linear interpolation between `a` and `b` at `t`. */ +export const lerp = (a: number, b: number, t: number): number => a + (b - a) * t; -/** Clamp `n` into the inclusive `[0, 1]` range. */ +/** Clamp into `[0, 1]`. */ export const clamp01 = (n: number): number => Math.min(1, Math.max(0, n)); diff --git a/src/lib/shared/playback.ts b/src/lib/shared/playback.ts new file mode 100644 index 0000000..f204a74 --- /dev/null +++ b/src/lib/shared/playback.ts @@ -0,0 +1,37 @@ +/** + * The WAAPI transport controls shared by every `AnimationController`. + * + * Both controllers (the `animate()` one and the view-transition one) drive a + * *group* of `Animation` objects that share a clock, so pause/play/reverse/seek + * are the same fan-out in both. Keeping the implementation here stops the two + * from drifting — particularly `seek`, which must swallow the throw from + * setting `currentTime` on an already-cancelled animation. + * + * DOM-typed but framework-free, so either package can import it without + * pulling in the other. + */ + +/** The subset of `AnimationController` that is pure fan-out over the group. */ +export interface PlaybackControls { + pause(): void; + play(): void; + reverse(): void; + seek(timeMs: number): void; +} + +/** Build the transport controls for a group visited by `forEachAnim`. */ +export const playbackControls = ( + forEachAnim: (fn: (animation: Animation) => void) => void +): PlaybackControls => ({ + pause: () => forEachAnim((a) => a.pause()), + play: () => forEachAnim((a) => a.play()), + reverse: () => forEachAnim((a) => a.reverse()), + seek: (timeMs: number) => + forEachAnim((a) => { + try { + a.currentTime = timeMs; + } catch { + // Animation may have been cancelled — ignore. + } + }) +}); diff --git a/src/lib/shared/spring-core.test.ts b/src/lib/shared/spring-core.test.ts index b37ef6a..4518bfb 100644 --- a/src/lib/shared/spring-core.test.ts +++ b/src/lib/shared/spring-core.test.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest'; -import { getCachedSpring, sampleAt, samplesToLinearEasing } from './spring-core'; +import { getCachedSpring, sampleAt, samplesToLinearEasing, spring } from './spring-core'; // --------------------------------------------------------------------------- // samplesToLinearEasing() @@ -124,3 +124,40 @@ describe('getCachedSpring()', () => { expect(reborn.linearEasingCss).toBe(first.linearEasingCss); }); }); + +// --------------------------------------------------------------------------- +// spring() +// --------------------------------------------------------------------------- + +describe('spring()', () => { + it('returns samples starting at 0 and ending at 1', () => { + const { samples } = spring(); + expect(samples[0]).toBe(0); + expect(samples[samples.length - 1]).toBe(1); + }); + + it('duration is samples.length - 1 frames at 60fps', () => { + const { samples, duration } = spring(); + expect(duration).toBeCloseTo((samples.length - 1) * (1000 / 60), 1); + }); + + it('stiffer spring settles faster', () => { + const stiff = spring({ stiffness: 500, damping: 40 }); + const soft = spring({ stiffness: 50, damping: 8 }); + expect(stiff.duration).toBeLessThan(soft.duration); + }); + + it('respects custom restDelta / restSpeed thresholds', () => { + const tight = spring({ restDelta: 1e-6, restSpeed: 1e-6 }); + const loose = spring({ restDelta: 0.05, restSpeed: 0.05 }); + // Tighter tolerance = more frames = longer duration + expect(tight.duration).toBeGreaterThanOrEqual(loose.duration); + }); + + it('all samples are finite numbers', () => { + const { samples } = spring({ stiffness: 300, damping: 18, velocity: 200 }); + for (const s of samples) { + expect(Number.isFinite(s)).toBe(true); + } + }); +}); diff --git a/src/lib/shared/spring-core.ts b/src/lib/shared/spring-core.ts index e66314a..8e4cf63 100644 --- a/src/lib/shared/spring-core.ts +++ b/src/lib/shared/spring-core.ts @@ -11,7 +11,8 @@ import type { SpringOptions } from './types'; const DT_MS = 1000 / 60; const MAX_DURATION_MS = 10_000; -const SPRING_DEFAULTS = { +/** The library's default spring feel — shared by the sampled spring and the live integrator. */ +export const SPRING_DEFAULTS = { stiffness: 170, damping: 26, mass: 1, @@ -89,6 +90,13 @@ export const getCachedSpring = (options: SpringOptions = {}): CachedSpring => { return entry; }; +/** + * Simulate a spring travelling from 0 → 1 and return per-frame normalized + * samples plus the settling duration. Results are memoized by option key. + */ +export const spring = (options: SpringOptions = {}): SpringSamples => + getCachedSpring(options).spring; + const simulateSpring = (options: SpringOptions): SpringSamples => { const { stiffness, damping, mass, restDelta, restSpeed, velocity } = withSpringDefaults(options); diff --git a/src/lib/text/README.md b/src/lib/text/README.md deleted file mode 100644 index 710cf36..0000000 --- a/src/lib/text/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# text - -SplitText — break an element's text into per-character, per-word, or per-line wrapper ``s so the pieces can be animated individually. The GSAP-SplitText analog. Pair the returned spans with [`stagger()`](../animate/README.md) for cascading reveals. - -| File | Responsibility | -| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `split.ts` | `tokenizeWords(text)` / `tokenizeChars(text)` — pure, DOM-free tokenizers (whitespace-preserving, surrogate-pair-aware) that are server-testable. `splitText(element, options)` — the DOM wrapper that builds `inline-block` spans, groups chars inside word spans (so words never fracture at line ends), measures `offsetTop` to bucket words into line wrappers, and returns the spans plus a `revert()`. SSR-safe via [`isBrowser`](../shared). | - -```svelte - - -

Reveal me, one letter at a time

-``` - -### Options - -| Option | Default | Description | -| ----------- | --------- | --------------------------------------------------------------------------------------- | -| `type` | `'chars'` | `'chars'`, `'words'`, `'lines'`, or an array of these — which granularities to produce. | -| `charClass` | — | Class applied to every character wrapper span. | -| `wordClass` | — | Class applied to every word wrapper span. | -| `lineClass` | — | Class applied to every line wrapper span. | - -### Result - -`splitText()` returns `{ chars, words, lines, revert }`: - -| Field | Type | Description | -| -------- | --------------- | ---------------------------------------------------------- | -| `chars` | `HTMLElement[]` | Per-character spans. Empty unless `'chars'` was requested. | -| `words` | `HTMLElement[]` | Per-word spans. Empty unless `'words'` was requested. | -| `lines` | `HTMLElement[]` | Per-line spans. Empty unless `'lines'` was requested. | -| `revert` | `() => void` | Restores the element's original `innerHTML`. | - -> **Words stay intact.** Word spans are always built internally (even when only `chars` is requested) and carry `white-space: nowrap`, so characters never break mid-word at a line end. **Lines** are grouped by measuring each word span's `offsetTop` after layout — words sharing an offset are on the same visual line — so the line buckets reflect the element's _current_ width. Re-run after a resize if the wrap changes. -> -> **SSR.** Outside a browser, `splitText()` returns empty arrays and a no-op `revert`, so it is safe to call unconditionally; do the actual split in `onMount`. diff --git a/src/lib/text/split.ts b/src/lib/text/split.ts index bfa2e97..911ae4f 100644 --- a/src/lib/text/split.ts +++ b/src/lib/text/split.ts @@ -8,7 +8,7 @@ * wrapper that builds the spans and is browser-only. */ -import { isBrowser } from '$lib/shared/browser'; +import { isBrowser } from '../shared/browser'; /** Which granularity / granularities to split into. Default: `'chars'`. */ export type SplitType = 'chars' | 'words' | 'lines'; diff --git a/src/lib/variants/README.md b/src/lib/variants/README.md deleted file mode 100644 index 057c69e..0000000 --- a/src/lib/variants/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# variants - -Named animation states layered over [`animate()`](../animate/README.md). Define a set of states once, then move between them by name — the orchestration primitive for interactive components (rest / hover / pressed, open / closed, …). - -Each transition interrupts the previous one by committing the live on-screen values first (via the controller's `stop()`), so re-targeting mid-flight starts from where the element actually is instead of snapping back. - -| File | Responsibility | -| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `variants.ts` | `createVariants(element, options)` — the framework-agnostic imperative core. `to(name)` animates to a state, `apply(name)` snaps to it, `stop()` commits the in-flight values. `current` reports the last targeted state. | -| `variants.svelte.ts` | `variants(options)` — a Svelte attachment that drives `createVariants` from reactive state. Pass an `active: () => string` thunk; an `$effect` animates to that state whenever it changes. Snaps to the initial state on mount (or animates it with `animateInitial`). | - -```svelte - - - -``` - -Prefer the imperative `createVariants()` when you are not in a Svelte component or want to drive states from your own event handling. diff --git a/src/lib/variants/index.ts b/src/lib/variants/index.ts index df4e6b1..ebbe8a3 100644 --- a/src/lib/variants/index.ts +++ b/src/lib/variants/index.ts @@ -5,7 +5,10 @@ * - `variants()` — reactive Svelte attachment driven by a state thunk. */ -export { createVariants } from './variants'; -export type { VariantMap, VariantsOptions, VariantsController } from './variants'; -export { variants } from './variants.svelte'; -export type { VariantsAttachmentOptions } from './variants.svelte'; +export { createVariants, variants } from './variants.svelte'; +export type { + VariantMap, + VariantsOptions, + VariantsController, + VariantsAttachmentOptions +} from './variants.svelte'; diff --git a/src/lib/variants/variants.svelte.ts b/src/lib/variants/variants.svelte.ts index 6d0031f..9f41627 100644 --- a/src/lib/variants/variants.svelte.ts +++ b/src/lib/variants/variants.svelte.ts @@ -1,7 +1,13 @@ /** - * `variants()` — a Svelte 5 attachment that drives {@link createVariants} from - * reactive state. Pass a thunk returning the active state name; the element - * animates to that state whenever it changes. + * Variants — named animation states layered over `animate()`. + * + * Define a set of states once, then move between them by name. Each transition + * interrupts the previous one by committing the live values first (via the + * controller's `stop()`), so re-targeting mid-flight starts from the on-screen + * position instead of snapping. + * + * `createVariants()` is the imperative controller; `variants()` is the Svelte + * attachment that drives it from reactive state. * * @example * ```svelte @@ -24,10 +30,82 @@ import { untrack } from 'svelte'; import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '$lib/shared/browser'; -import type { MotionElement } from '$lib/animate'; -import { createVariants, type VariantMap } from './variants'; -import type { AnimateDefaults } from '$lib/animate'; +import { isBrowser } from '../shared/browser'; +import { animate } from '../animate'; +import type { AnimateDefaults, AnimateProps, AnimationController, MotionElement } from '../animate'; + +/** Map of state name → target props. */ +export type VariantMap = Record; + +export interface VariantsOptions { + /** The named states. */ + variants: VariantMap; + /** State to apply immediately on creation (no animation). */ + initial?: string; + /** Shared `animate()` defaults applied to every transition. */ + defaults?: AnimateDefaults; +} + +export interface VariantsController { + /** The most recently targeted state name. */ + readonly current: string | null; + /** Animate to a named state. Returns the controller, or `null` if unknown. */ + to(name: string, overrides?: AnimateDefaults): AnimationController | null; + /** Snap to a named state instantly (no animation). */ + apply(name: string): void; + /** Stop the in-flight transition, committing the current values. */ + stop(): void; +} + +/** + * Create an imperative variants controller bound to `element`. + * + * @example + * ```ts + * const v = createVariants(node, { + * variants: { rest: { scale: 1 }, hover: { scale: 1.05 } }, + * initial: 'rest', + * }); + * v.to('hover'); + * ``` + */ +export const createVariants = ( + element: MotionElement, + options: VariantsOptions +): VariantsController => { + const { variants, defaults, initial } = options; + let current: string | null = null; + let active: AnimationController | null = null; + + const apply = (name: string): void => { + const props = variants[name]; + if (!props) return; + active?.stop(); + active = animate(element, props, { ...defaults, duration: 0 }); + current = name; + }; + + const to = (name: string, overrides?: AnimateDefaults): AnimationController | null => { + const props = variants[name]; + if (!props) return null; + // Commit the in-flight position so the new transition starts from there. + active?.stop(); + current = name; + active = animate(element, props, { ...defaults, ...overrides }); + return active; + }; + + if (initial != null) apply(initial); + + return { + get current() { + return current; + }, + to, + apply, + stop: () => active?.stop() + }; +}; export interface VariantsAttachmentOptions { /** The named states. */ diff --git a/src/lib/variants/variants.ts b/src/lib/variants/variants.ts deleted file mode 100644 index 8e9679e..0000000 --- a/src/lib/variants/variants.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Variants — named animation states layered over `animate()`. - * - * Define a set of states once, then move between them by name. Each transition - * interrupts the previous one by committing the live values first (via the - * controller's `stop()`), so re-targeting mid-flight starts from the on-screen - * position instead of snapping. - * - * This is the framework-agnostic imperative core; `variants()` (the Svelte - * attachment) drives it from reactive state. - * - * @example - * ```ts - * const v = createVariants(node, { - * variants: { - * rest: { scale: 1, y: 0 }, - * hover: { scale: 1.05, y: -4 }, - * pressed: { scale: 0.96 }, - * }, - * initial: 'rest', - * defaults: { spring: { stiffness: 300, damping: 24 } }, - * }); - * v.to('hover'); - * ``` - */ - -import { animate } from '$lib/animate'; -import type { - AnimateDefaults, - AnimateProps, - AnimationController, - MotionElement -} from '$lib/animate'; - -/** Map of state name → target props. */ -export type VariantMap = Record; - -export interface VariantsOptions { - /** The named states. */ - variants: VariantMap; - /** State to apply immediately on creation (no animation). */ - initial?: string; - /** Shared `animate()` defaults applied to every transition. */ - defaults?: AnimateDefaults; -} - -export interface VariantsController { - /** The most recently targeted state name. */ - readonly current: string | null; - /** Animate to a named state. Returns the controller, or `null` if unknown. */ - to(name: string, overrides?: AnimateDefaults): AnimationController | null; - /** Snap to a named state instantly (no animation). */ - apply(name: string): void; - /** Stop the in-flight transition, committing the current values. */ - stop(): void; -} - -/** Create an imperative variants controller bound to `element`. */ -export const createVariants = ( - element: MotionElement, - options: VariantsOptions -): VariantsController => { - const { variants, defaults, initial } = options; - let current: string | null = null; - let active: AnimationController | null = null; - - const apply = (name: string): void => { - const props = variants[name]; - if (!props) return; - active?.stop(); - active = animate(element, props, { ...defaults, duration: 0 }); - current = name; - }; - - const to = (name: string, overrides?: AnimateDefaults): AnimationController | null => { - const props = variants[name]; - if (!props) return null; - // Commit the in-flight position so the new transition starts from there. - active?.stop(); - current = name; - active = animate(element, props, { ...defaults, ...overrides }); - return active; - }; - - if (initial != null) apply(initial); - - return { - get current() { - return current; - }, - to, - apply, - stop: () => active?.stop() - }; -}; diff --git a/src/lib/view-transition/README.md b/src/lib/view-transition/README.md deleted file mode 100644 index e982e5f..0000000 --- a/src/lib/view-transition/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# view-transition - -Drives the browser's native [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) with pulse's spring/easing engine. The browser still computes the old→new geometry; pulse **re-eases** the resulting pseudo-element animations, so you get GPU-composited morphs shaped by a spring — something plain CSS-driven view transitions can't do — exposed through the same [`AnimationController`](../animate/README.md) every other feature returns. - -| File | Responsibility | -| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `view-transition.ts` | `viewTransition(update, options)` — wraps `document.startViewTransition`, returns an `AnimationController`. `supportsViewTransitions()` feature-detects. Falls back to running `update` un-animated when the API is missing (Firefox, older Safari), under `prefers-reduced-motion`, or during SSR. | -| `controller.ts` | Wraps the running `ViewTransition` in the uniform controller. After `transition.ready` it collects the live `::view-transition-*` pseudo animations, re-times them with the resolved easing, and forwards `seek`/`pause`/`reverse`/`finished` to them. `resolvedController` is the no-transition fast-path. | -| `timing.ts` | `resolveViewTransitionTiming(options)` — pure (DOM-free, node-tested) resolution of `spring`/`easing`/`duration` into an `EffectTiming` patch. Reuses `springEasing()` and `easingToCss()`. | -| `name.svelte.ts` | `viewTransitionName(name)` — Svelte attachment that sets `view-transition-name` (static or reactive thunk) so an element morphs as a shared element. The counterpart of `flip({ layoutId })`. | -| `navigation.ts` | `viewTransitionNavigate(navigation, options)` — wrap a SvelteKit client navigation. Call from `onNavigate`. `navigation` is typed structurally (`{ complete }`) so the module never imports `$app/navigation`. | - -```svelte - - -
- {#each items as item (item.id)} - -
{item.label}
- {/each} -
- -``` - -### SvelteKit page transitions - -```ts -import { onNavigate } from '$app/navigation'; -import { viewTransitionNavigate } from '@ixirjs/pulse/view-transition'; - -onNavigate((navigation) => viewTransitionNavigate(navigation, { spring: true })); -``` - -### Notes - -- **Re-easing, not re-computing.** The native API positions the snapshots; pulse only reshapes the timing curve. Omit `spring`/`easing`/`duration` to keep the browser default. -- **Controllability.** Because the morph runs on real WAAPI `Animation` objects, `seek`/`pause`/`reverse` work — but only once `transition.ready` has resolved (the pseudo-elements exist). `cancel()`/`stop()` skip to the end state and take effect immediately. -- **Interactive local transitions.** The View Transitions specification removes captured elements from hit-testing while a transition is active. For an interruptible local morph, set `html { view-transition-name: none }`, put `viewTransitionName()` on a non-interactive child rather than its button/link, and let the transition pseudo-elements pass pointer input with `::view-transition { pointer-events: none }`. -- **Interruptions.** Starting another document transition cancels the active one, and the specification permits their asynchronous update callbacks to overlap. If updates depend on previous state, call `stop()`, await `finished`, and discard superseded requests before starting the replacement transition. -- **Relationship to FLIP.** [`flip()`](../flip/README.md) remains the choice for fully interruptible, retargetable layout animation and for shared-element transitions that must work in every browser. View transitions add native cross-document (MPA) navigations and zero manual rect bookkeeping where supported. diff --git a/src/lib/view-transition/controller.ts b/src/lib/view-transition/controller.ts index 8a69ec3..1840cdb 100644 --- a/src/lib/view-transition/controller.ts +++ b/src/lib/view-transition/controller.ts @@ -11,7 +11,8 @@ * controllable. */ -import type { AnimationController } from '$lib/animate/types'; +import type { AnimationController } from '../animate/types'; +import { playbackControls } from '../shared/playback'; import { resolveViewTransitionTiming } from './timing'; import type { ViewTransitionOptions } from './types'; @@ -100,17 +101,7 @@ export const createViewTransitionController = ( // state to commit — `stop()` finishes the morph immediately, like `cancel` // minus the hard reset. stop: skip, - pause: () => forEachAnim((a) => a.pause()), - play: () => forEachAnim((a) => a.play()), - reverse: () => forEachAnim((a) => a.reverse()), - seek: (timeMs: number) => - forEachAnim((a) => { - try { - a.currentTime = timeMs; - } catch { - /* animation may have been cancelled — ignore */ - } - }) + ...playbackControls(forEachAnim) }; }; diff --git a/src/lib/view-transition/name.svelte.ts b/src/lib/view-transition/name.svelte.ts index 730462c..758c98a 100644 --- a/src/lib/view-transition/name.svelte.ts +++ b/src/lib/view-transition/name.svelte.ts @@ -5,8 +5,8 @@ */ import type { Attachment } from 'svelte/attachments'; -import { isBrowser } from '$lib/shared/browser'; -import type { MotionElement } from '$lib/shared/types'; +import { isBrowser } from '../shared/browser'; +import type { MotionElement } from '../shared/types'; /** A static name, or a thunk read reactively so the name can track `$state`. */ export type ViewTransitionNameInput = string | (() => string | undefined); diff --git a/src/lib/view-transition/navigation.ts b/src/lib/view-transition/navigation.ts index 660c715..252701c 100644 --- a/src/lib/view-transition/navigation.ts +++ b/src/lib/view-transition/navigation.ts @@ -1,12 +1,12 @@ /** * SvelteKit glue — wrap a view transition around a client-side navigation. * - * Kept framework-decoupled: instead of importing `$app/navigation` (a - * SvelteKit-only virtual module), `navigation` is typed structurally so the + * Kept framework-decoupled: rather than importing SvelteKit's navigation + * module, `navigation` is typed structurally so the * helper works with any object exposing a `complete` promise. */ -import { isBrowser, shouldReduceMotion } from '$lib/shared/browser'; +import { isBrowser, shouldReduceMotion } from '../shared/browser'; import { supportsViewTransitions, viewTransition } from './view-transition'; import type { ViewTransitionOptions } from './types'; @@ -22,11 +22,7 @@ export interface NavigationLike { * swapping the DOM. Returns `undefined` (a plain navigation) when the API is * unavailable or reduced motion is requested. * - * @example - * ```ts - * import { onNavigate } from '$app/navigation'; - * onNavigate((nav) => viewTransitionNavigate(nav, { spring: true })); - * ``` + * Register this with SvelteKit's `onNavigate` hook and return its result. */ export const viewTransitionNavigate = ( navigation: NavigationLike, diff --git a/src/lib/view-transition/timing.test.ts b/src/lib/view-transition/timing.test.ts index ceefbe1..5369f49 100644 --- a/src/lib/view-transition/timing.test.ts +++ b/src/lib/view-transition/timing.test.ts @@ -3,7 +3,7 @@ */ import { describe, expect, it } from 'vitest'; -import { easeOut } from '$lib/easing'; +import { backOut, easeOut } from '$lib/easing'; import { resolveViewTransitionTiming } from './timing'; describe('resolveViewTransitionTiming()', () => { @@ -32,17 +32,23 @@ describe('resolveViewTransitionTiming()', () => { }); it('resamples an EasingFn to a linear() string', () => { - const timing = resolveViewTransitionTiming({ easing: easeOut, duration: 300 }); + const timing = resolveViewTransitionTiming({ easing: backOut, duration: 300 }); expect(timing?.easing).toMatch(/^linear\(/); expect(timing?.duration).toBe(300); }); it('omits duration for an EasingFn when none is given', () => { - const timing = resolveViewTransitionTiming({ easing: easeOut }); + const timing = resolveViewTransitionTiming({ easing: backOut }); expect(timing?.easing).toMatch(/^linear\(/); expect(timing && 'duration' in timing).toBe(false); }); + it('emits the CSS keyword for an easing that has an exact keyword form', () => { + // `easeOut` *is* cubic-bezier(0, 0, 0.58, 1) — no reason to ship an + // approximation of a curve the browser implements natively. + expect(resolveViewTransitionTiming({ easing: easeOut })).toEqual({ easing: 'ease-out' }); + }); + it('passes a CSS easing keyword through unchanged', () => { expect(resolveViewTransitionTiming({ easing: 'ease-in-out' })).toEqual({ easing: 'ease-in-out' diff --git a/src/lib/view-transition/timing.ts b/src/lib/view-transition/timing.ts index 64c1230..f6ccbe1 100644 --- a/src/lib/view-transition/timing.ts +++ b/src/lib/view-transition/timing.ts @@ -4,8 +4,8 @@ * browser integration in `controller.ts`. */ -import { springEasing } from '$lib/easing'; -import { easingToCss } from '$lib/animate/keyframes/easing-utils'; +import { springEasing } from '../easing'; +import { easingToCss } from '../animate/keyframes/easing-utils'; import type { ViewTransitionOptions } from './types'; /** diff --git a/src/lib/view-transition/types.ts b/src/lib/view-transition/types.ts index 864fbfd..9cfc432 100644 --- a/src/lib/view-transition/types.ts +++ b/src/lib/view-transition/types.ts @@ -2,7 +2,7 @@ * Public types for the `view-transition` module. */ -import type { EasingFn, SpringInput } from '$lib/animate/types'; +import type { EasingFn, SpringInput } from '../animate/types'; export type { EasingFn, SpringInput }; diff --git a/src/lib/view-transition/view-transition.ts b/src/lib/view-transition/view-transition.ts index c5d4249..ed24dd5 100644 --- a/src/lib/view-transition/view-transition.ts +++ b/src/lib/view-transition/view-transition.ts @@ -3,8 +3,8 @@ * spring/easing engine, behind the uniform {@link AnimationController}. */ -import { isBrowser, shouldReduceMotion } from '$lib/shared/browser'; -import type { AnimationController } from '$lib/animate/types'; +import { isBrowser, shouldReduceMotion } from '../shared/browser'; +import type { AnimationController } from '../animate/types'; import { createViewTransitionController, resolvedController } from './controller'; import type { ViewTransitionOptions, ViewTransitionUpdate } from './types'; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b872fc1..f3d2df7 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,1134 +1,13 @@ -{#snippet trigger(label: string, onclick: () => void, disabled = false)} - -{/snippet} - -
-
-
-

pulse

-

A WAAPI motion library for Svelte 5.

-
- - 26 examples - -
- -
- - c.stop()); - controllers = boxes.map((el, i) => { - const lift = -Math.sin((i / (boxes.length - 1)) * Math.PI) * 38; - return animate(el, { - y: on ? lift : 0, - borderRadius: on ? 18 : 6, - backgroundColor: on ? '#a855f7' : '#6366f1', - }, { delay: i * 55, duration: 520, easing: backOut }); - }); -}`} - > -
- {#each range(BASIC_COUNT) as i (i)} -
- {/each} -
- {@render trigger(basicOn ? 'Reset' : 'Launch', runBasic)} -
- - - - animate(el, { - scale: { to: 1.2, spring: { stiffness: 240 - i * 8, damping: 10 } }, - rotate: { to: 45, spring: { stiffness: 200, damping: 12 } }, - }) -);`} - > -
- {#each range(9) as i (i)} -
- {/each} -
- {@render trigger(springActive ? 'Reset' : 'Spring', triggerSpring)} -
- - - c.stop()); -controllers = els.map((el, i) => - animate(el, { opacity: show ? 1 : 0, scale: show ? 1 : 0.3 }, { - delay: delay(i, els.length), - duration: 320, - easing: backOut, - }) -);`} - > -
- {#each range(STAGGER_COUNT) as i (i)} -
- {/each} -
- {@render trigger(staggerVisible ? 'Hide' : 'Show', toggleStagger)} -
- - - -
-
- New -
-
Card reveal
-
- Each child enters on its own offset. -
- -
- {@render trigger('Replay', runTimeline)} -
- - - { void items; } })}> - {item} -
-{/each}`} - > -
- {#each flipItems as item (item)} -
{ - void flipItems; - } - })} - class="flex size-12 items-center justify-center rounded-lg bg-amber-100 text-xl" - > - {item} -
- {/each} -
- {@render trigger('Shuffle', shuffleFlip)} - - - - -
-

{expanded ? 'Expanded' : 'Collapsed'}

- {#if expanded} -

- Captured before the DOM update, animated from the old rect to the new. -

- {/if} -
- -
- - - `} - > -
- {#each ['drag', 'me', 'too'] as label, i (label)} -
- {label} -
- {/each} -
-
- - - state, - initial: 'rest', - variants: { - rest: { scale: 1, y: 0 }, - hover: { scale: 1.06, y: -4 }, - pressed: { scale: 0.95, y: 0 }, - }, - defaults: { spring: { stiffness: 320, damping: 20 } }, -})} />`} - > -
- {#each ['Tap', 'Me', 'Now'] as label, i (label)} - - {/each} -
-
- - - animate(el, { opacity: [0, 1], y: [24, 0] }), -})} />`} - > -
- {#each ['Lazy', 'Reveal', 'On', 'Scroll', 'Into', 'View'] as label (label)} -
- animate(el, { opacity: [0, 1], y: [20, 0] }, { duration: 420, easing: easeOut }) - })} - class="flex h-14 items-center justify-center rounded-lg bg-orange-100 text-xs font-medium text-orange-800 opacity-0" - > - {label} -
- {/each} -
-
- - - - {chip.label} - -{/each}`} - > - -
- {#each chips as chip (chip.id)} - - {/each} -
- {@render trigger('+ Add', addChip)} -
- - - - {row.label} -
-{/each} - -// axis: 'x' | 'y' | 'both' · start: 0..1 · opacity?`} - > - -
- {#each rows as row (row.id)} -
- {row.label} - -
- {/each} -
- {@render trigger('+ Add row', addRow)} - - - - -
- {#each range(6) as i (i)} -
- {/each} -
- {@render trigger('Tween', toggleGradient)} -
- - - -
- {#each range(PATHS.length) as i (i)} -
- {/each} -
- {@render trigger('Travel', runPath)} -
- - - - draw(p, { duration: 1100, delay: i * 180, easing: easeInOut }) -); // animates stroke-dashoffset from length → 0`} - > - - {#each ['M5,25 C 50,-5 70,55 100,25 S 160,-5 195,25', 'M5,45 C 50,15 70,75 100,45 S 160,15 195,45', 'M5,65 C 50,35 70,95 100,65 S 160,35 195,65'] as d, i (d)} - - {/each} - - {@render trigger('Draw', runDraw)} - - - - -
-
- - - -
optimize: true
-
-
- - - -
optimize: false
-
-
- {@render trigger('Morph', runMorph)} -
- - - -
- {#each range(5) as i (i)} -
- {/each} -
- {@render trigger('Sequence', runKeyframes)} -
- - - - animate(c, { opacity: [0, 1], y: [24, 0], rotate: [-35, 0] }, - { delay: delay(i, chars.length), duration: 440, easing: backOut }) -);`} - > -
- Animate every letter -
- {@render trigger('Reveal', runSplit)} -
- - - list, onReorder: (next) => (list = next) }); - -{#each list as value (value)} -
{value}
-{/each}`} - > -
- {#each reorderList as value (value)} -
- - {value} -
- {/each} -
-
- - - (label.textContent = \`\${v}%\`) });`} - > -
0
- {@render trigger('Count up', runCount)} -
- - - (pct = Math.round(progress * 100)), -});`} - > -
- - - - -
- {ringPct}% -
-
- {@render trigger('Run', runProgress)} -
- - - `} - > -
-
- pinch / rotate -
-
-

Use a touch screen or trackpad pinch.

-
- - - `} - > -
- -
-

Move your cursor across the button.

-
- - - { - if (Math.abs(info.x) > 100 || Math.abs(info.velocityX) > 500) - animate(el, { x: info.x > 0 ? 360 : -360, opacity: 0 }) - .finished.then(() => dismiss(value)); -} })} />`} - > -
- {#each swipeItems as value (value)} -
maybeDismiss(value, info, el) - })} - {@attach flip({ - duration: 260, - easing: easeOut, - auto: () => { - void swipeItems; - } - })} - class="flex cursor-grab items-center justify-between rounded-lg bg-cyan-100 px-3 py-2 text-xs font-medium text-cyan-800 select-none active:cursor-grabbing" - > - {value} - -
- {/each} -
- {@render trigger('Reset', resetSwipe, swipeItems.length === SWIPE_ITEMS.length)} -
- - - animate(el, { scale: 1.1, y: -4 }), - onFocusEnd: (el) => animate(el, { scale: 1, y: 0 }), -})} />`} - > -
- {#each ['Tab', 'through', 'these'] as label (label)} - - {/each} -
-

Press Tab to move focus.

-
- - - animate(el, { scale: 0.92 }), - onPressEnd: (el) => animate(el, { scale: 1 }), - onPress: () => (status = 'Tap'), - onLongPress: () => (status = 'Long press!'), - onDoubleTap: () => (status = 'Double tap!'), -})} />`} - > -
- - {pressStatus} -
-
- - - `} - > -
-
- scroll to zoom -
-
-

Hover and scroll, or trackpad pinch.

-
- - - (null); - -function select(id: string) { - viewTransition(async () => { - selected = selected === id ? null : id; - await tick(); // let Svelte flush before the "after" snapshot - }, { spring: { stiffness: 220, damping: 26 } }); -} - -{#if selected} - {@const item = items.find((i) => i.id === selected)} - -{/if} -
- {#each items.filter((i) => i.id !== selected) as item (item.id)} - - {/each} -
+ + Pulse — motion primitives for Svelte + + -`} - > -
- {#if vtSelected} - {@const item = vtItems.find((i) => i.id === vtSelected)!} - - {/if} -
- {#each vtItems.filter((i) => i.id !== vtSelected) as item (item.id)} - - {/each} -
-
-

- Tap a tile to expand it. Chrome & Safari morph; Firefox swaps instantly. -

-
-
- + diff --git a/src/routes/DemoCard.svelte b/src/routes/DemoCard.svelte index 2376502..1b25fda 100644 --- a/src/routes/DemoCard.svelte +++ b/src/routes/DemoCard.svelte @@ -12,19 +12,23 @@
-
-

{title}

+
+

+ {title} +

-
+
{@render children()}
@@ -32,19 +36,20 @@ -
- {title} +
+ {title}
-
{code}
diff --git a/src/routes/MotionLab.svelte b/src/routes/MotionLab.svelte new file mode 100644 index 0000000..0884462 --- /dev/null +++ b/src/routes/MotionLab.svelte @@ -0,0 +1,1287 @@ + + +{#snippet trigger(label: string, onclick: () => void, disabled = false)} + +{/snippet} + +
+ + +
+ + +
+

+ Built on the Web Animations API +

+

+ Motion primitives for + Svelte 5. +

+

+ Springs, FLIP, gestures, scroll and view transitions as small composable functions. No + runtime dependencies, no wrapper components. +

+
+ npm install @ixirjs/pulse + Browse the demos +
+

+ 26 live demos · 11 modules · 0 dependencies +

+
+ +
+
+

+ Every primitive, live +

+

+ Each card runs the real implementation. Open the code to see how. +

+
+ +
+ + c.stop()); + controllers = boxes.map((el, i) => { + const lift = -Math.sin((i / (boxes.length - 1)) * Math.PI) * 38; + return animate(el, { + y: on ? lift : 0, + borderRadius: on ? 18 : 6, + backgroundColor: on ? '#8b5cf6' : '#6366f1', + }, { delay: i * 55, duration: 520, easing: backOut }); + }); +}`} + > +
+ {#each range(BASIC_COUNT) as i (i)} +
+ {/each} +
+ {@render trigger(basicOn ? 'Reset' : 'Launch', runBasic)} +
+ + + + animate(el, { + scale: { to: 1.2, spring: { stiffness: 240 - i * 8, damping: 10 } }, + rotate: { to: 45, spring: { stiffness: 200, damping: 12 } }, + }) +);`} + > +
+ {#each range(9) as i (i)} +
+ {/each} +
+ {@render trigger(springActive ? 'Reset' : 'Spring', triggerSpring)} +
+ + + c.stop()); +controllers = els.map((el, i) => + animate(el, { opacity: show ? 1 : 0, scale: show ? 1 : 0.3 }, { + delay: delay(i, els.length), + duration: 320, + easing: backOut, + }) +);`} + > +
+ {#each range(STAGGER_COUNT) as i (i)} +
+ {/each} +
+ {@render trigger(staggerVisible ? 'Hide' : 'Show', toggleStagger)} +
+ + + +
+
+ New +
+
Card reveal
+
+ Each child enters on its own offset. +
+ +
+ {@render trigger('Replay', runTimeline)} +
+ + + + {item} +
+{/each}`} + > +
+ {#each flipItems as item (item)} +
+ {item} +
+ {/each} +
+ {@render trigger('Shuffle', shuffleFlip)} + + + + +
(expanded ? 'w-48 h-24' : 'w-16 h-16') + })} + class="rounded-lg bg-indigo-600" +>
`} + > +
+
(flipExpanded ? 'w-48 h-24' : 'w-16 h-16') + })} + class="rounded-lg bg-indigo-600" + >
+
+ {@render trigger('Toggle', () => (flipExpanded = !flipExpanded))} +
+ + + +
+

{expanded ? 'Expanded' : 'Collapsed'}

+ {#if expanded} +

+ Captured before the DOM update, animated from the old rect to the new. +

+ {/if} +
+ +
+ + + `} + > +
+ {#each ['drag', 'me', 'too'] as label, i (label)} +
+ {label} +
+ {/each} +
+
+ + + state, + initial: 'rest', + variants: { + rest: { scale: 1, y: 0 }, + hover: { scale: 1.06, y: -4 }, + pressed: { scale: 0.95, y: 0 }, + }, + defaults: { spring: { stiffness: 320, damping: 20 } }, +})} />`} + > +
+ {#each ['Tap', 'Me', 'Now'] as label, i (label)} + + {/each} +
+
+ + + animate(el, { opacity: [0, 1], y: [24, 0] }), +})} />`} + > +
+ {#each ['Lazy', 'Reveal', 'On', 'Scroll', 'Into', 'View'] as label (label)} +
+ animate(el, { opacity: [0, 1], y: [20, 0] }, { duration: 420, easing: easeOut }) + })} + class="flex h-14 items-center justify-center rounded-lg bg-indigo-50 text-xs font-medium text-indigo-700 opacity-0 dark:bg-indigo-500/10 dark:text-indigo-300" + > + {label} +
+ {/each} +
+
+ + + + {chip.label} + +{/each}`} + > + +
+ {#each chips as chip (chip.id)} + + {/each} +
+ {@render trigger('+ Add', addChip)} +
+ + + + {row.label} +
+{/each} + +// axis: 'x' | 'y' | 'both' · start: 0..1 · opacity?`} + > + +
+ {#each rows as row (row.id)} +
+ {row.label} + +
+ {/each} +
+ {@render trigger('+ Add row', addRow)} + + + + +
+ {#each range(6) as i (i)} +
+ {/each} +
+ {@render trigger('Tween', toggleGradient)} +
+ + + +
+ {#each range(PATHS.length) as i (i)} +
+ {/each} +
+ {@render trigger('Travel', runPath)} +
+ + + + draw(p, { duration: 1100, delay: i * 180, easing: easeInOut }) +); // animates stroke-dashoffset from length → 0`} + > + + {#each ['M5,25 C 50,-5 70,55 100,25 S 160,-5 195,25', 'M5,45 C 50,15 70,75 100,45 S 160,15 195,45', 'M5,65 C 50,35 70,95 100,65 S 160,35 195,65'] as d, i (d)} + + {/each} + + {@render trigger('Draw', runDraw)} + + + + +
+
+ + + +
+ optimize: true +
+
+
+ + + +
+ optimize: false +
+
+
+ {@render trigger('Morph', runMorph)} +
+ + + +
+ {#each range(5) as i (i)} +
+ {/each} +
+ {@render trigger('Sequence', runKeyframes)} +
+ + + + animate(c, { opacity: [0, 1], y: [24, 0], rotate: [-35, 0] }, + { delay: delay(i, chars.length), duration: 440, easing: backOut }) +);`} + > +
+ Animate every letter +
+ {@render trigger('Reveal', runSplit)} +
+ + + list, onReorder: (next) => (list = next) }); + +{#each list as value (value)} +
{value}
+{/each}`} + > +
+ {#each reorderList as value (value)} +
+ + {value} +
+ {/each} +
+
+ + + (label.textContent = \`\${v}%\`) });`} + > +
+ 0 +
+ {@render trigger('Count up', runCount)} +
+ + + (pct = Math.round(progress * 100)), +});`} + > +
+ + + + +
+ {ringPct}% +
+
+ {@render trigger('Run', runProgress)} +
+ + + `} + > +
+
+ pinch / rotate +
+
+

+ Use a touch screen or trackpad pinch. +

+
+ + + `} + > +
+ +
+

+ Move your cursor across the button. +

+
+ + + { + if (Math.abs(info.x) > 100 || Math.abs(info.velocityX) > 500) + animate(el, { x: info.x > 0 ? 360 : -360, opacity: 0 }) + .finished.then(() => dismiss(value)); +} })} />`} + > +
+ {#each swipeItems as value (value)} +
maybeDismiss(value, info, el) + })} + {@attach flip({ duration: 260, easing: easeOut })} + class="flex cursor-grab items-center justify-between rounded-lg bg-indigo-50 px-3 py-2 text-xs font-medium text-indigo-700 select-none active:cursor-grabbing dark:bg-indigo-500/10 dark:text-indigo-300" + > + {value} + +
+ {/each} +
+ {@render trigger('Reset', resetSwipe, swipeItems.length === SWIPE_ITEMS.length)} +
+ + + animate(el, { scale: 1.1, y: -4 }), + onFocusEnd: (el) => animate(el, { scale: 1, y: 0 }), +})} />`} + > +
+ {#each ['Tab', 'through', 'these'] as label (label)} + + {/each} +
+

+ Press Tab to move focus. +

+
+ + + animate(el, { scale: 0.92 }), + onPressEnd: (el) => animate(el, { scale: 1 }), + onPress: () => (status = 'Tap'), + onLongPress: () => (status = 'Long press!'), + onDoubleTap: () => (status = 'Double tap!'), +})} />`} + > +
+ + {pressStatus} +
+
+ + + `} + > +
+
+ scroll to zoom +
+
+

+ Hover and scroll, or trackpad pinch. +

+
+ + + (null); + +function select(id: string) { + viewTransition(async () => { + selected = selected === id ? null : id; + await tick(); // let Svelte flush before the "after" snapshot + }, { spring: { stiffness: 220, damping: 26 } }); +} + +{#if selected} + {@const item = items.find((i) => i.id === selected)} + +{/if} +
+ {#each items.filter((i) => i.id !== selected) as item (item.id)} + + {/each} +
+ +`} + > +
+ {#if vtSelected} + {@const item = vtItems.find((i) => i.id === vtSelected)!} + + {/if} +
+ {#each vtItems.filter((i) => i.id !== vtSelected) as item (item.id)} + + {/each} +
+
+

+ Tap a tile to expand it. Chrome & Safari morph; Firefox swaps instantly. +

+
+ + + +
+ @ixirjs/pulse + Motion primitives for Svelte 5 +
+ + diff --git a/src/routes/layout.css b/src/routes/layout.css index 9ca01ab..a6ee489 100644 --- a/src/routes/layout.css +++ b/src/routes/layout.css @@ -1,5 +1,17 @@ @import 'tailwindcss'; +/* Class-based dark mode, toggled on and persisted in localStorage. */ +@custom-variant dark (&:where(.dark, .dark *)); + +/* Dotted paper background, tuned per theme. */ +.dots { + background-image: radial-gradient(circle, rgb(0 0 0 / 0.14) 1px, transparent 1px); + background-size: 22px 22px; +} +.dark .dots { + background-image: radial-gradient(circle, rgb(255 255 255 / 0.09) 1px, transparent 1px); +} + /* This page uses local shared-element transitions. Capturing `root` would make * the entire live document ineligible for hit-testing during every morph. */ html { diff --git a/src/stories/Button.stories.svelte b/src/stories/Button.stories.svelte deleted file mode 100644 index 7187089..0000000 --- a/src/stories/Button.stories.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - diff --git a/src/stories/Button.svelte b/src/stories/Button.svelte deleted file mode 100644 index c34befe..0000000 --- a/src/stories/Button.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - - diff --git a/src/stories/Configure.mdx b/src/stories/Configure.mdx deleted file mode 100644 index cd2ed52..0000000 --- a/src/stories/Configure.mdx +++ /dev/null @@ -1,388 +0,0 @@ -import { Meta } from "@storybook/addon-docs/blocks"; - -import Github from "./assets/github.svg"; -import Discord from "./assets/discord.svg"; -import Youtube from "./assets/youtube.svg"; -import Tutorials from "./assets/tutorials.svg"; -import Styling from "./assets/styling.png"; -import Context from "./assets/context.png"; -import Assets from "./assets/assets.png"; -import Docs from "./assets/docs.png"; -import Share from "./assets/share.png"; -import FigmaPlugin from "./assets/figma-plugin.png"; -import Testing from "./assets/testing.png"; -import Accessibility from "./assets/accessibility.png"; -import Theming from "./assets/theming.png"; -import AddonLibrary from "./assets/addon-library.png"; - -export const RightArrow = () => - - - - - -
-
- # Configure your project - - Because Storybook works separately from your app, you'll need to configure it for your specific stack and setup. Below, explore guides for configuring Storybook with popular frameworks and tools. If you get stuck, learn how you can ask for help from our community. -
-
-
- A wall of logos representing different styling technologies -

Add styling and CSS

-

Like with web applications, there are many ways to include CSS within Storybook. Learn more about setting up styling within Storybook.

- Learn more -
-
- An abstraction representing the composition of data for a component -

Provide context and mocking

-

Often when a story doesn't render, it's because your component is expecting a specific environment or context (like a theme provider) to be available.

- Learn more -
-
- A representation of typography and image assets -
-

Load assets and resources

-

To link static files (like fonts) to your projects and stories, use the - `staticDirs` configuration option to specify folders to load when - starting Storybook.

- Learn more -
-
-
-
-
-
- # Do more with Storybook - - Now that you know the basics, let's explore other parts of Storybook that will improve your experience. This list is just to get you started. You can customise Storybook in many ways to fit your needs. -
- -
-
-
- A screenshot showing the autodocs tag being set, pointing a docs page being generated -

Autodocs

-

Auto-generate living, - interactive reference documentation from your components and stories.

- Learn more -
-
- A browser window showing a Storybook being published to a chromatic.com URL -

Publish to Chromatic

-

Publish your Storybook to review and collaborate with your entire team.

- Learn more -
-
- Windows showing the Storybook plugin in Figma -

Figma Plugin

-

Embed your stories into Figma to cross-reference the design and live - implementation in one place.

- Learn more -
-
- Screenshot of tests passing and failing -

Testing

-

Use stories to test a component in all its variations, no matter how - complex.

- Learn more -
-
- Screenshot of accessibility tests passing and failing -

Accessibility

-

Automatically test your components for a11y issues as you develop.

- Learn more -
-
- Screenshot of Storybook in light and dark mode -

Theming

-

Theme Storybook's UI to personalize it to your project.

- Learn more -
-
-
-
-
-
-

Addons

-

Integrate your tools with Storybook to connect workflows.

- Discover all addons -
-
- Integrate your tools with Storybook to connect workflows. -
-
- -
-
- Github logo - Join our contributors building the future of UI development. - - Star on GitHub -
-
- Discord logo -
- Get support and chat with frontend developers. - - Join Discord server -
-
-
- Youtube logo -
- Watch tutorials, feature previews and interviews. - - Watch on YouTube -
-
-
- A book -

Follow guided walkthroughs on for key workflows.

- - Discover tutorials -
-
- - diff --git a/src/stories/Header.stories.svelte b/src/stories/Header.stories.svelte deleted file mode 100644 index 23175a8..0000000 --- a/src/stories/Header.stories.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - - - - diff --git a/src/stories/Header.svelte b/src/stories/Header.svelte deleted file mode 100644 index d6b7b21..0000000 --- a/src/stories/Header.svelte +++ /dev/null @@ -1,45 +0,0 @@ - - -
-
-
- - - - - - - -

Acme

-
-
- {#if user} - - Welcome, {user.name}! - -
-
-
diff --git a/src/stories/Page.stories.svelte b/src/stories/Page.stories.svelte deleted file mode 100644 index 8f2ff22..0000000 --- a/src/stories/Page.stories.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - - { - const canvas = within(canvasElement); - const loginButton = canvas.getByRole('button', { name: /Log in/i }); - await expect(loginButton).toBeInTheDocument(); - await userEvent.click(loginButton); - await waitFor(() => expect(loginButton).not.toBeInTheDocument()); - - const logoutButton = canvas.getByRole('button', { name: /Log out/i }); - await expect(logoutButton).toBeInTheDocument(); - }} -/> - - diff --git a/src/stories/Page.svelte b/src/stories/Page.svelte deleted file mode 100644 index c4c069a..0000000 --- a/src/stories/Page.svelte +++ /dev/null @@ -1,70 +0,0 @@ - - -
-
(user = { name: 'Jane Doe' })} - onLogout={() => (user = undefined)} - onCreateAccount={() => (user = { name: 'Jane Doe' })} - /> - -
-

Pages in Storybook

-

- We recommend building UIs with a - - component-driven - - process starting with atomic components and ending with pages. -

-

- Render pages with mock data. This makes it easy to build and review page states without - needing to navigate to them in your app. Here are some handy patterns for managing page data - in Storybook: -

-
    -
  • - Use a higher-level connected component. Storybook helps you compose such data from the - "args" of child component stories -
  • -
  • - Assemble data in the page component from your services. You can mock these services out - using Storybook. -
  • -
-

- Get a guided tutorial on component-driven development at - - Storybook tutorials - - . Read more in the - docs - . -

-
- Tip - Adjust the width of the canvas with the - - - - - - Viewports addon in the toolbar -
-
-
diff --git a/src/stories/assets/accessibility.png b/src/stories/assets/accessibility.png deleted file mode 100644 index 6ffe6fe..0000000 Binary files a/src/stories/assets/accessibility.png and /dev/null differ diff --git a/src/stories/assets/accessibility.svg b/src/stories/assets/accessibility.svg deleted file mode 100644 index 107e93f..0000000 --- a/src/stories/assets/accessibility.svg +++ /dev/null @@ -1 +0,0 @@ -Accessibility \ No newline at end of file diff --git a/src/stories/assets/addon-library.png b/src/stories/assets/addon-library.png deleted file mode 100644 index 95deb38..0000000 Binary files a/src/stories/assets/addon-library.png and /dev/null differ diff --git a/src/stories/assets/assets.png b/src/stories/assets/assets.png deleted file mode 100644 index cfba681..0000000 Binary files a/src/stories/assets/assets.png and /dev/null differ diff --git a/src/stories/assets/avif-test-image.avif b/src/stories/assets/avif-test-image.avif deleted file mode 100644 index 530709b..0000000 Binary files a/src/stories/assets/avif-test-image.avif and /dev/null differ diff --git a/src/stories/assets/context.png b/src/stories/assets/context.png deleted file mode 100644 index e5cd249..0000000 Binary files a/src/stories/assets/context.png and /dev/null differ diff --git a/src/stories/assets/discord.svg b/src/stories/assets/discord.svg deleted file mode 100644 index d638958..0000000 --- a/src/stories/assets/discord.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/stories/assets/docs.png b/src/stories/assets/docs.png deleted file mode 100644 index a749629..0000000 Binary files a/src/stories/assets/docs.png and /dev/null differ diff --git a/src/stories/assets/figma-plugin.png b/src/stories/assets/figma-plugin.png deleted file mode 100644 index 8f79b08..0000000 Binary files a/src/stories/assets/figma-plugin.png and /dev/null differ diff --git a/src/stories/assets/github.svg b/src/stories/assets/github.svg deleted file mode 100644 index dc51352..0000000 --- a/src/stories/assets/github.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/stories/assets/share.png b/src/stories/assets/share.png deleted file mode 100644 index 8097a37..0000000 Binary files a/src/stories/assets/share.png and /dev/null differ diff --git a/src/stories/assets/styling.png b/src/stories/assets/styling.png deleted file mode 100644 index d341e82..0000000 Binary files a/src/stories/assets/styling.png and /dev/null differ diff --git a/src/stories/assets/testing.png b/src/stories/assets/testing.png deleted file mode 100644 index d4ac39a..0000000 Binary files a/src/stories/assets/testing.png and /dev/null differ diff --git a/src/stories/assets/theming.png b/src/stories/assets/theming.png deleted file mode 100644 index 1535eb9..0000000 Binary files a/src/stories/assets/theming.png and /dev/null differ diff --git a/src/stories/assets/tutorials.svg b/src/stories/assets/tutorials.svg deleted file mode 100644 index b492a9c..0000000 --- a/src/stories/assets/tutorials.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/stories/assets/youtube.svg b/src/stories/assets/youtube.svg deleted file mode 100644 index a7515d7..0000000 --- a/src/stories/assets/youtube.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/stories/button.css b/src/stories/button.css deleted file mode 100644 index 4e3620b..0000000 --- a/src/stories/button.css +++ /dev/null @@ -1,30 +0,0 @@ -.storybook-button { - display: inline-block; - cursor: pointer; - border: 0; - border-radius: 3em; - font-weight: 700; - line-height: 1; - font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; -} -.storybook-button--primary { - background-color: #555ab9; - color: white; -} -.storybook-button--secondary { - box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset; - background-color: transparent; - color: #333; -} -.storybook-button--small { - padding: 10px 16px; - font-size: 12px; -} -.storybook-button--medium { - padding: 11px 20px; - font-size: 14px; -} -.storybook-button--large { - padding: 12px 24px; - font-size: 16px; -} diff --git a/src/stories/header.css b/src/stories/header.css deleted file mode 100644 index 5efd46c..0000000 --- a/src/stories/header.css +++ /dev/null @@ -1,32 +0,0 @@ -.storybook-header { - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 1px solid rgba(0, 0, 0, 0.1); - padding: 15px 20px; - font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; -} - -.storybook-header svg { - display: inline-block; - vertical-align: top; -} - -.storybook-header h1 { - display: inline-block; - vertical-align: top; - margin: 6px 0 6px 10px; - font-weight: 700; - font-size: 20px; - line-height: 1; -} - -.storybook-header button + button { - margin-left: 10px; -} - -.storybook-header .welcome { - margin-right: 10px; - color: #333; - font-size: 14px; -} diff --git a/src/stories/page.css b/src/stories/page.css deleted file mode 100644 index 77c81d2..0000000 --- a/src/stories/page.css +++ /dev/null @@ -1,68 +0,0 @@ -.storybook-page { - margin: 0 auto; - padding: 48px 20px; - max-width: 600px; - color: #333; - font-size: 14px; - line-height: 24px; - font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; -} - -.storybook-page h2 { - display: inline-block; - vertical-align: top; - margin: 0 0 4px; - font-weight: 700; - font-size: 32px; - line-height: 1; -} - -.storybook-page p { - margin: 1em 0; -} - -.storybook-page a { - color: inherit; -} - -.storybook-page ul { - margin: 1em 0; - padding-left: 30px; -} - -.storybook-page li { - margin-bottom: 8px; -} - -.storybook-page .tip { - display: inline-block; - vertical-align: top; - margin-right: 10px; - border-radius: 1em; - background: #e7fdd8; - padding: 4px 12px; - color: #357a14; - font-weight: 700; - font-size: 11px; - line-height: 12px; -} - -.storybook-page .tip-wrapper { - margin-top: 40px; - margin-bottom: 40px; - font-size: 13px; - line-height: 20px; -} - -.storybook-page .tip-wrapper svg { - display: inline-block; - vertical-align: top; - margin-top: 3px; - margin-right: 4px; - width: 12px; - height: 12px; -} - -.storybook-page .tip-wrapper svg path { - fill: #1ea7fd; -} diff --git a/svelte.config.js b/svelte.config.js index 0442732..0e93b3b 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -5,7 +5,7 @@ import adapter from '@sveltejs/adapter-auto'; const config = { compilerOptions: { // Force runes mode for the project, except for libraries. Can be removed in svelte 6. - runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true + runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true) }, kit: { // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. diff --git a/vite.config.ts b/vite.config.ts index a53cf91..9458c51 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,62 +1,42 @@ -/// import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'vitest/config'; import { playwright } from '@vitest/browser-playwright'; import { sveltekit } from '@sveltejs/kit/vite'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; -const dirname = typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); -// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon export default defineConfig({ - plugins: [tailwindcss(), sveltekit()], - test: { - expect: { - requireAssertions: true - }, - projects: [{ - extends: './vite.config.ts', - test: { - name: 'client', - browser: { - enabled: true, - provider: playwright(), - instances: [{ - browser: 'chromium', - headless: true - }] - }, - include: ['src/**/*.svelte.{test,spec}.{js,ts}'], - exclude: ['src/lib/server/**'] - } - }, { - extends: './vite.config.ts', - test: { - name: 'server', - environment: 'node', - include: ['src/**/*.{test,spec}.{js,ts}'], - exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] - } - }, { - extends: true, - plugins: [ - // The plugin will run tests for the stories defined in your Storybook config - // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest - storybookTest({ - configDir: path.join(dirname, '.storybook') - })], - test: { - name: 'storybook', - browser: { - enabled: true, - headless: true, - provider: playwright({}), - instances: [{ - browser: 'chromium' - }] - } - } - }] - } -}); \ No newline at end of file + plugins: [tailwindcss(), sveltekit()], + test: { + expect: { + requireAssertions: true + }, + projects: [ + { + extends: './vite.config.ts', + test: { + name: 'client', + browser: { + enabled: true, + provider: playwright(), + instances: [ + { + browser: 'chromium', + headless: true + } + ] + }, + include: ['src/**/*.svelte.{test,spec}.{js,ts}'], + exclude: ['src/lib/server/**'] + } + }, + { + extends: './vite.config.ts', + test: { + name: 'server', + environment: 'node', + include: ['src/**/*.{test,spec}.{js,ts}'], + exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] + } + } + ] + } +}); diff --git a/vitest.shims.d.ts b/vitest.shims.d.ts index 7782f28..03b1801 100644 --- a/vitest.shims.d.ts +++ b/vitest.shims.d.ts @@ -1 +1 @@ -/// \ No newline at end of file +///