Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/animated-control.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions packages/dom/src/Animation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type {
AnimationEndResult,
AnimationHook,
AnimationOptions,
EnterOnlyAnimationOptions,
ListAnimationOptions,
StaggerFunction,
} from "./types.js";
Expand Down
21 changes: 21 additions & 0 deletions packages/dom/src/Animation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 92 additions & 1 deletion packages/dom/src/Control/Control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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)),
);
});
});
79 changes: 79 additions & 0 deletions packages/dom/src/Control/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import * as Element from "../Element/index.js";
import { AnimationConfigCtx } from "./AnimationConfigCtx.js";
import type {
AnimatedConfig,
EachConfig,
MatchConfig,
MatchEitherConfig,
Expand All @@ -33,6 +34,7 @@ import type {

// Re-export types
export type {
AnimatedConfig,
WhenConfig,
MatchConfig,
MatchCase,
Expand Down Expand Up @@ -289,3 +291,80 @@ export const redraw = <T extends Readable.Readable<unknown>>(
})
: (x) => x,
) as Element.Element<DOMElement, never, ControlCtx>;

/**
* 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 = <E = never, R = never>(
config: AnimatedConfig,
render: () => Element.Element<DOMElement, E, R>,
): Element.Element<DOMElement, E, R | ControlCtx> =>
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<DOMElement, E, R | ControlCtx>,
config.animate || config.intro
? Effect.provideService(AnimationConfigCtx, {
single: config.animate,
intro: config.intro,
})
: (x) => x,
) as Element.Element<DOMElement, E, R | ControlCtx>;
31 changes: 31 additions & 0 deletions packages/dom/src/Control/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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).
*/
Expand Down
41 changes: 40 additions & 1 deletion packages/dom/src/Render/server/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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");
});
});
});
Loading
Loading