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 {
|
export interface PresenceChildProps {
|
||||||
|
/** Whether the content is currently present (tracks props.present directly). */
|
||||||
present: Accessor<boolean>;
|
present: Accessor<boolean>;
|
||||||
|
/** True briefly after present transitions from false→true. For enter animations. */
|
||||||
opening: Accessor<boolean>;
|
opening: Accessor<boolean>;
|
||||||
|
/** True briefly after present transitions from true→false. For exit animations. */
|
||||||
closing: Accessor<boolean>;
|
closing: Accessor<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -11,54 +14,72 @@ export interface PresenceProps {
|
|||||||
present: boolean;
|
present: boolean;
|
||||||
/**
|
/**
|
||||||
* When true, keeps children mounted even when present is false.
|
* 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;
|
forceMount?: boolean;
|
||||||
children: JSX.Element | ((props: PresenceChildProps) => JSX.Element);
|
children: JSX.Element | ((props: PresenceChildProps) => JSX.Element);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps exiting elements mounted during CSS/JS animations.
|
* Controls mount/unmount of children with enter/exit animation support.
|
||||||
* Emits data-opening and data-closing attributes for CSS-driven transitions.
|
* Exposes opening and closing signals via the children-as-function API.
|
||||||
*
|
*
|
||||||
* Usage with CSS:
|
* Usage with children-as-function:
|
||||||
* .content[data-opening] { animation: fadeIn 200ms; }
|
* <Presence present={open()}>
|
||||||
* .content[data-closing] { animation: fadeOut 150ms; }
|
* {({ opening, closing }) => (
|
||||||
|
* <div classList={{ opening: opening(), closing: closing() }}>…</div>
|
||||||
|
* )}
|
||||||
|
* </Presence>
|
||||||
*/
|
*/
|
||||||
export function Presence(props: PresenceProps): JSX.Element {
|
export function Presence(props: PresenceProps): JSX.Element {
|
||||||
const [mounted, setMounted] = createSignal(props.present);
|
const [mounted, setMounted] = createSignal(props.present);
|
||||||
const [opening, setOpening] = createSignal(false);
|
const [opening, setOpening] = createSignal(false);
|
||||||
const [closing, setClosing] = createSignal(false);
|
const [closing, setClosing] = createSignal(false);
|
||||||
|
|
||||||
createEffect(() => {
|
// defer: true skips the initial run — only react to changes after mount.
|
||||||
if (props.present) {
|
createEffect(
|
||||||
setMounted(true);
|
on(
|
||||||
setClosing(false);
|
() => props.present,
|
||||||
setOpening(true);
|
(isPresent) => {
|
||||||
// Clear opening flag after a microtask so CSS can pick it up
|
if (isPresent) {
|
||||||
queueMicrotask(() => setOpening(false));
|
setMounted(true);
|
||||||
} else {
|
setClosing(false);
|
||||||
setOpening(false);
|
setOpening(true);
|
||||||
setClosing(true);
|
// Clear opening after a microtask — gives consumers one reactive tick
|
||||||
// Keep mounted while closing — consumers use forceMount for animation control
|
// to read the opening state before it resets.
|
||||||
setClosing(false);
|
queueMicrotask(() => setOpening(false));
|
||||||
setMounted(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 shouldMount = () => props.forceMount || mounted();
|
||||||
|
|
||||||
const childProps: PresenceChildProps = {
|
const childProps: PresenceChildProps = {
|
||||||
present: mounted,
|
// Reflect props.present directly — no reactive lag from the mounted signal.
|
||||||
|
present: () => props.present,
|
||||||
opening,
|
opening,
|
||||||
closing,
|
closing,
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolved = children(() =>
|
// Wrap in a function so Show evaluates children lazily (only when mounted).
|
||||||
typeof props.children === "function"
|
return (
|
||||||
? (props.children as (p: PresenceChildProps) => JSX.Element)(childProps)
|
<Show when={shouldMount()}>
|
||||||
: props.children,
|
{() =>
|
||||||
|
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();
|
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 [present, setPresent] = createSignal(false);
|
||||||
const { queryByTestId } = render(() => (
|
const { queryByTestId } = render(() => (
|
||||||
<Presence present={present()}>
|
<Presence present={present()}>
|
||||||
<div data-testid="content">hello</div>
|
<div data-testid="content">hello</div>
|
||||||
</Presence>
|
</Presence>
|
||||||
));
|
));
|
||||||
|
expect(queryByTestId("content")).toBeNull();
|
||||||
setPresent(true);
|
setPresent(true);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
const el = queryByTestId("content");
|
expect(queryByTestId("content")).toBeTruthy();
|
||||||
expect(el).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(() => (
|
const { getByTestId } = render(() => (
|
||||||
<Presence present={false} forceMount>
|
<Presence present={false} forceMount>
|
||||||
<div data-testid="content">hello</div>
|
<div data-testid="content">hello</div>
|
||||||
@ -43,4 +56,41 @@ describe("Presence", () => {
|
|||||||
));
|
));
|
||||||
expect(getByTestId("content")).toBeTruthy();
|
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