From d95a27a08deed0f6840f9566cd057e566e3d8458 Mon Sep 17 00:00:00 2001 From: Jon Laing Date: Thu, 16 Jul 2026 14:48:21 -0400 Subject: [PATCH] feat: animated control for mount-once elements with enter animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New control function 'animated' for wrapping a single element with an enter animation. Renders once and never removes — designed for hand-authored intro sequences where each/when/match don't quite fit. Type surface is enter-only via a new EnterOnlyAnimationOptions type (Pick of AnimationOptions) — no exit fields, since the element is never removed. Group and intro flags work the same as on the other controls: group lives inside the animate config (consistent with each/when/match), intro triggers SSR enterFrom emission and hydration re-animation. Tests cover: mount, client-side enter animation, group sequencing, SSR enterFrom emission, SSR opt-out when intro is omitted. Co-Authored-By: Claude Opus 4.7 --- .changeset/animated-control.md | 47 ++++++++++ packages/dom/src/Animation/index.ts | 1 + packages/dom/src/Animation/types.ts | 21 +++++ packages/dom/src/Control/Control.test.ts | 93 ++++++++++++++++++- packages/dom/src/Control/index.ts | 79 ++++++++++++++++ packages/dom/src/Control/types.ts | 31 +++++++ packages/dom/src/Render/server/server.test.ts | 41 +++++++- packages/dom/src/index.ts | 3 + 8 files changed, 314 insertions(+), 2 deletions(-) create mode 100644 .changeset/animated-control.md diff --git a/.changeset/animated-control.md b/.changeset/animated-control.md new file mode 100644 index 0000000..d465957 --- /dev/null +++ b/.changeset/animated-control.md @@ -0,0 +1,47 @@ +--- +"@effex/dom": minor +--- + +Add `animated` — a mount-once control function for wrapping a single element with enter animations. Solves the hand-authored intro-sequence case that was awkward under `each` / `when` (either had to shoehorn a static list into `each` or fake a boolean signal for `when`). + +```ts +const App = () => + Effect.gen(function* () { + const [g0, g1, g2] = yield* Animation.sequence(3); + return $.h1( + {}, + collect( + animated( + { + animate: { + enterFrom: "opacity-0", + enter: "opacity-100 transition duration-300", + group: g0, + }, + intro: true, + }, + () => $.span({}, $.of("Hello,")), + ), + // No visual animation — the span has its own CSS keyframes; the + // group still sequences when it appears. + animated( + { animate: { group: g1 } }, + () => $.span({ class: "wobble" }, $.of("world!")), + ), + animated( + { + animate: { + enterFrom: "opacity-0", + enter: "opacity-100 transition duration-500", + group: g2, + }, + intro: true, + }, + () => $.span({}, $.of("Welcome.")), + ), + ), + ); + }); +``` + +**Enter-only by design.** `animated` mounts its child and never removes it, so exit-animation fields would be dead code. The `animate` config uses the new `EnterOnlyAnimationOptions` type (a `Pick` of `AnimationOptions`) — the compiler prevents you from configuring `exit` / `exitTo` / `onExit`. `group` and `intro` behave the same as they do on `each` / `when` / `match`, including SSR emitting `enterFrom` classes for FOUC prevention when `intro: true`. diff --git a/packages/dom/src/Animation/index.ts b/packages/dom/src/Animation/index.ts index ecc9691..6de4ce6 100644 --- a/packages/dom/src/Animation/index.ts +++ b/packages/dom/src/Animation/index.ts @@ -8,6 +8,7 @@ export type { AnimationEndResult, AnimationHook, AnimationOptions, + EnterOnlyAnimationOptions, ListAnimationOptions, StaggerFunction, } from "./types.js"; diff --git a/packages/dom/src/Animation/types.ts b/packages/dom/src/Animation/types.ts index a641e9e..9833290 100644 --- a/packages/dom/src/Animation/types.ts +++ b/packages/dom/src/Animation/types.ts @@ -187,6 +187,27 @@ export interface AnimationOptions { group?: AnimationGroup; } +/** + * Subset of {@link AnimationOptions} covering only the enter lifecycle. + * Used by control functions (like `animated`) that mount their content + * once and never remove it, so any exit-related fields would be dead code. + * + * Includes `group` so callers can wire the element into an + * {@link AnimationGroup} the same way they would with `each`/`when`/`match`: + * `{ animate: { group: g0, ... } }`. + */ +export type EnterOnlyAnimationOptions = Pick< + AnimationOptions, + | "enter" + | "enterFrom" + | "enterTo" + | "onBeforeEnter" + | "onEnter" + | "timeout" + | "respectReducedMotion" + | "group" +>; + /** * Stagger function that calculates delay for each item in a list * @param index - Zero-based index of the item diff --git a/packages/dom/src/Control/Control.test.ts b/packages/dom/src/Control/Control.test.ts index 3f71876..0ba267d 100644 --- a/packages/dom/src/Control/Control.test.ts +++ b/packages/dom/src/Control/Control.test.ts @@ -4,10 +4,11 @@ import { beforeEach, expect } from "vitest"; import { Readable, Signal } from "@effex/core"; +import { Animation } from "../Animation/index.js"; import { collect } from "../Collect.js"; import { $ } from "../Element/index.js"; import { DOMRendererLive } from "../Render/DOMRenderer.js"; -import { ClientControlCtx, each, match, when } from "./index.js"; +import { animated, ClientControlCtx, each, match, when } from "./index.js"; const TestLayer = Layer.mergeAll(ClientControlCtx, DOMRendererLive); @@ -686,4 +687,94 @@ describe("Control", () => { }).pipe(Effect.provide(TestLayer)), ); }); + + describe("animated", () => { + it.scopedLive("mounts its child inside a container", () => + Effect.gen(function* () { + const el = yield* animated({}, () => + $.span({ class: "greeting" }, $.of("Hello")), + ); + // Default container wraps the rendered child. + expect(el.children.length).toBe(1); + expect(el.children[0].tagName).toBe("SPAN"); + expect(el.children[0].textContent).toBe("Hello"); + }).pipe(Effect.provide(TestLayer)), + ); + + it.scopedLive( + "fires onBeforeEnter on client mount when animate is set", + () => + Effect.gen(function* () { + let called = 0; + yield* animated( + { + animate: { + enterFrom: "opacity-0", + enter: "opacity-100", + onBeforeEnter: () => + Effect.sync(() => { + called += 1; + }), + timeout: 10, + }, + }, + () => $.span({}, $.of("Hi")), + ); + // Give the forked enter animation a moment to reach onBeforeEnter. + yield* Effect.sleep("20 millis"); + expect(called).toBe(1); + }).pipe(Effect.provide(TestLayer)), + ); + + it.scopedLive("gates on an AnimationGroup", () => + Effect.gen(function* () { + // Two `animated` blocks in sequence — the second must not fire its + // onBeforeEnter until the first's group completes. + const [g0, g1] = yield* Animation.sequence(2); + const log: string[] = []; + + yield* animated( + { + animate: { + enter: "in", + group: g0, + onBeforeEnter: () => + Effect.sync(() => { + log.push("0-start"); + }), + onEnter: () => + Effect.sync(() => { + log.push("0-end"); + }), + timeout: 10, + }, + }, + () => $.span({}, $.of("First")), + ); + + yield* animated( + { + animate: { + enter: "in", + group: g1, + onBeforeEnter: () => + Effect.sync(() => { + log.push("1-start"); + }), + timeout: 10, + }, + }, + () => $.span({}, $.of("Second")), + ); + + // 0 fires immediately (g0 gate open), 1 waits for g0 to complete. + yield* Effect.sleep("50 millis"); + // 0-start must precede 0-end, and 1-start must be strictly after 0-end. + const zeroEnd = log.indexOf("0-end"); + const oneStart = log.indexOf("1-start"); + expect(zeroEnd).toBeGreaterThan(-1); + expect(oneStart).toBeGreaterThan(zeroEnd); + }).pipe(Effect.provide(TestLayer)), + ); + }); }); diff --git a/packages/dom/src/Control/index.ts b/packages/dom/src/Control/index.ts index 0339e9a..8ce880e 100644 --- a/packages/dom/src/Control/index.ts +++ b/packages/dom/src/Control/index.ts @@ -23,6 +23,7 @@ import { import * as Element from "../Element/index.js"; import { AnimationConfigCtx } from "./AnimationConfigCtx.js"; import type { + AnimatedConfig, EachConfig, MatchConfig, MatchEitherConfig, @@ -33,6 +34,7 @@ import type { // Re-export types export type { + AnimatedConfig, WhenConfig, MatchConfig, MatchCase, @@ -289,3 +291,80 @@ export const redraw = >( }) : (x) => x, ) as Element.Element; + +/** + * Mount-once wrapper for a single element with animation support. + * + * Renders `render()` inside a container and applies enter animations on + * mount (or on hydration when `intro: true` is set). Unlike `each` / + * `when` / `match`, `animated` doesn't reconcile against a signal — the + * element is inserted exactly once and stays. Exit-related animation + * fields are unavailable by construction (see {@link EnterOnlyAnimationOptions}). + * + * Use it to wrap sibling elements in a hand-authored intro sequence: + * + * @example Sequenced headline with mixed animation styles + * ```ts + * const App = () => + * Effect.gen(function* () { + * const [g0, g1, g2] = yield* Animation.sequence(3); + * return $.h1( + * {}, + * collect( + * animated( + * { + * animate: { + * enterFrom: "opacity-0", + * enter: "opacity-100 transition duration-300", + * group: g0, + * }, + * intro: true, + * }, + * () => $.span({}, $.of("Hello,")), + * ), + * animated( + * // No visual animation — the span has its own CSS keyframes; the + * // group still gates when it appears in the sequence. + * { animate: { group: g1 } }, + * () => $.span({ class: "wobble" }, $.of("world!")), + * ), + * animated( + * { + * animate: { + * enterFrom: "opacity-0", + * enter: "opacity-100 transition duration-500", + * group: g2, + * }, + * intro: true, + * }, + * () => $.span({}, $.of("Welcome.")), + * ), + * ), + * ); + * }); + * ``` + */ +export const animated = ( + config: AnimatedConfig, + render: () => Element.Element, +): Element.Element => + pipe( + Effect.gen(function* () { + const parentCtx = yield* ControlCtx; + const ctx = yield* parentCtx.fork(); + const container = yield* ctx.getContainer(config.container); + yield* ctx.addSlot("_", () => render(), { + atIndex: 0, + initialIndex: 0, + totalItems: 1, + }); + yield* ctx.finalizeContainer(); + return container; + }) as Element.Element, + config.animate || config.intro + ? Effect.provideService(AnimationConfigCtx, { + single: config.animate, + intro: config.intro, + }) + : (x) => x, + ) as Element.Element; diff --git a/packages/dom/src/Control/types.ts b/packages/dom/src/Control/types.ts index 4dd5730..8c8dae7 100644 --- a/packages/dom/src/Control/types.ts +++ b/packages/dom/src/Control/types.ts @@ -2,6 +2,7 @@ import type { Readable } from "@effex/core"; import type { AnimationOptions, + EnterOnlyAnimationOptions, ListAnimationOptions, } from "../Animation/index.js"; import type * as Element from "../Element/index.js"; @@ -151,6 +152,36 @@ export interface MatchEitherConfig< readonly intro?: boolean; } +/** + * Configuration for `animated` — a mount-once wrapper for a single element + * that applies enter animations on mount (or on hydration when `intro` is + * set) and can be sequenced across siblings via `animate.group`. + * + * Only supports enter-related animation options because the element is + * never removed; the type surface makes that explicit via {@link + * EnterOnlyAnimationOptions}. Group membership lives inside `animate.group` + * (same shape as `each`/`when`/`match`) so a pure-CSS element that only + * wants to sequence writes `{ animate: { group: g0 } }`. + */ +export interface AnimatedConfig { + /** + * Optional custom container element. If not provided, defaults to a div + * with `display: contents`. + */ + readonly container?: () => Element.Element< + HTMLElement | SVGElement, + never, + never + >; + /** Enter-lifecycle animation options (no exit). */ + readonly animate?: EnterOnlyAnimationOptions; + /** + * Re-animate this element's SSR/SSG-rendered content on hydration. See + * {@link EachConfig.intro} for the full contract and FOUC caveat. + */ + readonly intro?: boolean; +} + /** * Configuration for the `each` control flow (DOM-specific with animation support). */ diff --git a/packages/dom/src/Render/server/server.test.ts b/packages/dom/src/Render/server/server.test.ts index 01f7ac1..c51d514 100644 --- a/packages/dom/src/Render/server/server.test.ts +++ b/packages/dom/src/Render/server/server.test.ts @@ -5,7 +5,7 @@ import { Readable, Signal } from "@effex/core"; import { Boundary } from "../../Boundary.js"; import { collect } from "../../Collect.js"; -import { each, match, when } from "../../Control/index.js"; +import { animated, each, match, when } from "../../Control/index.js"; import { $ } from "../../Element/index.js"; import { renderToString } from "./index.js"; @@ -353,5 +353,44 @@ describe("SSR", () => { expect(html).toContain("opacity-0"); }); + + it("emits enterFrom on animated's SSR child when intro is set", async () => { + const html = await Effect.runPromise( + renderToString( + animated( + { + animate: { + enterFrom: "opacity-0 translate-y-2", + enter: "opacity-100 translate-y-0", + }, + intro: true, + }, + () => $.h1({}, $.of("Hero")), + ), + ), + ); + + expect(html).toContain("opacity-0"); + expect(html).toContain("translate-y-2"); + expect(html).toContain("Hero"); + }); + + it("does not emit enterFrom on animated when intro is omitted", async () => { + const html = await Effect.runPromise( + renderToString( + animated( + { + animate: { + enterFrom: "opacity-0", + enter: "opacity-100", + }, + }, + () => $.h1({}, $.of("Hero")), + ), + ), + ); + + expect(html).not.toContain("opacity-0"); + }); }); }); diff --git a/packages/dom/src/index.ts b/packages/dom/src/index.ts index 85673b1..e5ceed2 100644 --- a/packages/dom/src/index.ts +++ b/packages/dom/src/index.ts @@ -47,6 +47,7 @@ export { matchOption, matchEither, redraw, + animated, HydrationMismatchError, } from "./Control/index.js"; export type { @@ -57,6 +58,7 @@ export type { MatchOptionConfig, MatchEitherConfig, RedrawConfig, + AnimatedConfig, } from "./Control/index.js"; // Boundary (async and error handling) @@ -74,6 +76,7 @@ export type { AnimationGroup, AnimationHook, AnimationOptions, + EnterOnlyAnimationOptions, ListAnimationOptions, StaggerFunction, } from "./Animation/index.js";