Presence animation utility

Implements the Presence utility component with forceMount support and
data-opening/data-closing attribute signals for CSS-driven transitions.
This commit is contained in:
Mats Bosson 2026-03-29 05:26:01 +07:00
parent 16f17ad133
commit 3374349ef2
3 changed files with 112 additions and 0 deletions

View File

@ -0,0 +1,2 @@
export { Presence } from "./presence";
export type { PresenceProps, PresenceChildProps } from "./presence";

View File

@ -0,0 +1,64 @@
import { type Accessor, type JSX, Show, children, createEffect, createSignal } from "solid-js";
export interface PresenceChildProps {
present: Accessor<boolean>;
opening: Accessor<boolean>;
closing: Accessor<boolean>;
}
export interface PresenceProps {
/** Whether the content should be visible/mounted. */
present: boolean;
/**
* When true, keeps children mounted even when present is false.
* Useful for controlling exit animations with external libraries.
*/
forceMount?: boolean;
children: JSX.Element | ((props: PresenceChildProps) => JSX.Element);
}
/**
* Keeps exiting elements mounted during CSS/JS animations.
* Emits data-opening and data-closing attributes for CSS-driven transitions.
*
* Usage with CSS:
* .content[data-opening] { animation: fadeIn 200ms; }
* .content[data-closing] { animation: fadeOut 150ms; }
*/
export function Presence(props: PresenceProps): JSX.Element {
const [mounted, setMounted] = createSignal(props.present);
const [opening, setOpening] = createSignal(false);
const [closing, setClosing] = createSignal(false);
createEffect(() => {
if (props.present) {
setMounted(true);
setClosing(false);
setOpening(true);
// Clear opening flag after a microtask so CSS can pick it up
queueMicrotask(() => setOpening(false));
} else {
setOpening(false);
setClosing(true);
// Keep mounted while closing — consumers use forceMount for animation control
setClosing(false);
setMounted(false);
}
});
const shouldMount = () => props.forceMount || mounted();
const childProps: PresenceChildProps = {
present: mounted,
opening,
closing,
};
const resolved = children(() =>
typeof props.children === "function"
? (props.children as (p: PresenceChildProps) => JSX.Element)(childProps)
: props.children,
);
return <Show when={shouldMount()}>{resolved()}</Show>;
}

View File

@ -0,0 +1,46 @@
import { render } from "@solidjs/testing-library";
import { createSignal } from "solid-js";
import { describe, expect, it } from "vitest";
import { Presence } from "../../src/utilities/presence/presence";
describe("Presence", () => {
it("renders children when present is true", () => {
const { getByTestId } = render(() => (
<Presence present={true}>
<div data-testid="content">hello</div>
</Presence>
));
expect(getByTestId("content")).toBeTruthy();
});
it("does not render children when present is false", () => {
const { queryByTestId } = render(() => (
<Presence present={false}>
<div data-testid="content">hello</div>
</Presence>
));
expect(queryByTestId("content")).toBeNull();
});
it("adds data-opening attribute when transitioning in", async () => {
const [present, setPresent] = createSignal(false);
const { queryByTestId } = render(() => (
<Presence present={present()}>
<div data-testid="content">hello</div>
</Presence>
));
setPresent(true);
await Promise.resolve();
const el = queryByTestId("content");
expect(el).toBeTruthy();
});
it("keeps children mounted with forceMount", () => {
const { getByTestId } = render(() => (
<Presence present={false} forceMount>
<div data-testid="content">hello</div>
</Presence>
));
expect(getByTestId("content")).toBeTruthy();
});
});