Fix Presence reactivity
This commit is contained in:
parent
3374349ef2
commit
de85abf548
@ -1,8 +1,11 @@
|
||||
import { type Accessor, type JSX, Show, children, createEffect, createSignal } from "solid-js";
|
||||
import { type Accessor, type JSX, Show, createEffect, createSignal, on } from "solid-js";
|
||||
|
||||
export interface PresenceChildProps {
|
||||
/** Whether the content is currently present (tracks props.present directly). */
|
||||
present: Accessor<boolean>;
|
||||
/** True briefly after present transitions from false→true. For enter animations. */
|
||||
opening: Accessor<boolean>;
|
||||
/** True briefly after present transitions from true→false. For exit animations. */
|
||||
closing: Accessor<boolean>;
|
||||
}
|
||||
|
||||
@ -11,54 +14,72 @@ export interface PresenceProps {
|
||||
present: boolean;
|
||||
/**
|
||||
* When true, keeps children mounted even when present is false.
|
||||
* Useful for controlling exit animations with external libraries.
|
||||
* Use this when an external animation library controls unmounting.
|
||||
*/
|
||||
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.
|
||||
* Controls mount/unmount of children with enter/exit animation support.
|
||||
* Exposes opening and closing signals via the children-as-function API.
|
||||
*
|
||||
* Usage with CSS:
|
||||
* .content[data-opening] { animation: fadeIn 200ms; }
|
||||
* .content[data-closing] { animation: fadeOut 150ms; }
|
||||
* Usage with children-as-function:
|
||||
* <Presence present={open()}>
|
||||
* {({ opening, closing }) => (
|
||||
* <div classList={{ opening: opening(), closing: closing() }}>…</div>
|
||||
* )}
|
||||
* </Presence>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
});
|
||||
// defer: true skips the initial run — only react to changes after mount.
|
||||
createEffect(
|
||||
on(
|
||||
() => props.present,
|
||||
(isPresent) => {
|
||||
if (isPresent) {
|
||||
setMounted(true);
|
||||
setClosing(false);
|
||||
setOpening(true);
|
||||
// Clear opening after a microtask — gives consumers one reactive tick
|
||||
// to read the opening state before it resets.
|
||||
queueMicrotask(() => setOpening(false));
|
||||
} else {
|
||||
setOpening(false);
|
||||
setClosing(true);
|
||||
// In the baseline, unmount immediately (no animation duration known).
|
||||
// With forceMount, the consumer controls the lifecycle externally.
|
||||
if (!props.forceMount) {
|
||||
setMounted(false);
|
||||
}
|
||||
queueMicrotask(() => setClosing(false));
|
||||
}
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
);
|
||||
|
||||
const shouldMount = () => props.forceMount || mounted();
|
||||
|
||||
const childProps: PresenceChildProps = {
|
||||
present: mounted,
|
||||
// Reflect props.present directly — no reactive lag from the mounted signal.
|
||||
present: () => props.present,
|
||||
opening,
|
||||
closing,
|
||||
};
|
||||
|
||||
const resolved = children(() =>
|
||||
typeof props.children === "function"
|
||||
? (props.children as (p: PresenceChildProps) => JSX.Element)(childProps)
|
||||
: props.children,
|
||||
// Wrap in a function so Show evaluates children lazily (only when mounted).
|
||||
return (
|
||||
<Show when={shouldMount()}>
|
||||
{() =>
|
||||
typeof props.children === "function"
|
||||
? (props.children as (p: PresenceChildProps) => JSX.Element)(childProps)
|
||||
: props.children
|
||||
}
|
||||
</Show>
|
||||
);
|
||||
|
||||
return <Show when={shouldMount()}>{resolved()}</Show>;
|
||||
}
|
||||
|
||||
@ -22,20 +22,33 @@ describe("Presence", () => {
|
||||
expect(queryByTestId("content")).toBeNull();
|
||||
});
|
||||
|
||||
it("adds data-opening attribute when transitioning in", async () => {
|
||||
it("mounts children when present transitions false→true", async () => {
|
||||
const [present, setPresent] = createSignal(false);
|
||||
const { queryByTestId } = render(() => (
|
||||
<Presence present={present()}>
|
||||
<div data-testid="content">hello</div>
|
||||
</Presence>
|
||||
));
|
||||
expect(queryByTestId("content")).toBeNull();
|
||||
setPresent(true);
|
||||
await Promise.resolve();
|
||||
const el = queryByTestId("content");
|
||||
expect(el).toBeTruthy();
|
||||
expect(queryByTestId("content")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps children mounted with forceMount", () => {
|
||||
it("unmounts children when present transitions true→false", async () => {
|
||||
const [present, setPresent] = createSignal(true);
|
||||
const { queryByTestId } = render(() => (
|
||||
<Presence present={present()}>
|
||||
<div data-testid="content">hello</div>
|
||||
</Presence>
|
||||
));
|
||||
expect(queryByTestId("content")).toBeTruthy();
|
||||
setPresent(false);
|
||||
await Promise.resolve();
|
||||
expect(queryByTestId("content")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps children mounted with forceMount when present is false", () => {
|
||||
const { getByTestId } = render(() => (
|
||||
<Presence present={false} forceMount>
|
||||
<div data-testid="content">hello</div>
|
||||
@ -43,4 +56,41 @@ describe("Presence", () => {
|
||||
));
|
||||
expect(getByTestId("content")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("exposes opening signal via children-as-function", async () => {
|
||||
const [present, setPresent] = createSignal(false);
|
||||
let capturedOpening = false;
|
||||
|
||||
render(() => (
|
||||
<Presence present={present()}>
|
||||
{({ opening }) => {
|
||||
if (opening()) capturedOpening = true;
|
||||
return <div data-testid="content">hello</div>;
|
||||
}}
|
||||
</Presence>
|
||||
));
|
||||
|
||||
setPresent(true);
|
||||
// opening is true for one microtask after transitioning in
|
||||
await Promise.resolve();
|
||||
expect(capturedOpening).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes closing signal via children-as-function", async () => {
|
||||
const [present, setPresent] = createSignal(true);
|
||||
let capturedClosing = false;
|
||||
|
||||
render(() => (
|
||||
<Presence present={present()} forceMount>
|
||||
{({ closing }) => {
|
||||
if (closing()) capturedClosing = true;
|
||||
return <div data-testid="content">hello</div>;
|
||||
}}
|
||||
</Presence>
|
||||
));
|
||||
|
||||
setPresent(false);
|
||||
await Promise.resolve();
|
||||
expect(capturedClosing).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user