From 3374349ef24fdb6288925d9f7fc599018b838b4f Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 05:26:01 +0700 Subject: [PATCH] Presence animation utility Implements the Presence utility component with forceMount support and data-opening/data-closing attribute signals for CSS-driven transitions. --- packages/core/src/utilities/presence/index.ts | 2 + .../core/src/utilities/presence/presence.tsx | 64 +++++++++++++++++++ .../core/tests/utilities/presence.test.tsx | 46 +++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 packages/core/src/utilities/presence/index.ts create mode 100644 packages/core/src/utilities/presence/presence.tsx create mode 100644 packages/core/tests/utilities/presence.test.tsx diff --git a/packages/core/src/utilities/presence/index.ts b/packages/core/src/utilities/presence/index.ts new file mode 100644 index 0000000..4c7df3e --- /dev/null +++ b/packages/core/src/utilities/presence/index.ts @@ -0,0 +1,2 @@ +export { Presence } from "./presence"; +export type { PresenceProps, PresenceChildProps } from "./presence"; diff --git a/packages/core/src/utilities/presence/presence.tsx b/packages/core/src/utilities/presence/presence.tsx new file mode 100644 index 0000000..f062b80 --- /dev/null +++ b/packages/core/src/utilities/presence/presence.tsx @@ -0,0 +1,64 @@ +import { type Accessor, type JSX, Show, children, createEffect, createSignal } from "solid-js"; + +export interface PresenceChildProps { + present: Accessor; + opening: Accessor; + closing: Accessor; +} + +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 {resolved()}; +} diff --git a/packages/core/tests/utilities/presence.test.tsx b/packages/core/tests/utilities/presence.test.tsx new file mode 100644 index 0000000..b4377ed --- /dev/null +++ b/packages/core/tests/utilities/presence.test.tsx @@ -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(() => ( + +
hello
+
+ )); + expect(getByTestId("content")).toBeTruthy(); + }); + + it("does not render children when present is false", () => { + const { queryByTestId } = render(() => ( + +
hello
+
+ )); + expect(queryByTestId("content")).toBeNull(); + }); + + it("adds data-opening attribute when transitioning in", async () => { + const [present, setPresent] = createSignal(false); + const { queryByTestId } = render(() => ( + +
hello
+
+ )); + setPresent(true); + await Promise.resolve(); + const el = queryByTestId("content"); + expect(el).toBeTruthy(); + }); + + it("keeps children mounted with forceMount", () => { + const { getByTestId } = render(() => ( + +
hello
+
+ )); + expect(getByTestId("content")).toBeTruthy(); + }); +});