import { expect, userEvent, within } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type ReactNode, useRef, useState } from "react";
import { animationDurationsMs } from "../../animationDurationsMs";
import { ReducedMotionContext } from "../../core/ReducedMotionContext";
import { Button } from "../actions/Button";
import { InfoMessage } from "../feedback/Message";
import { Text } from "../primitives/Text";
import { View } from "../primitives/View";
import { Story } from "../story-components/Story";
import { Box } from "./Box";
import { PresenceList, PresenceOne } from "./Presence";
// Pure NativeWind v5 CSS animations — no animation library. Presence keeps the
// outgoing child mounted for `exitDurationMs` (the framework's
// `animationDurationsMs.slide`, matching the slide-in / slide-out keyframes) so
// the previous content slides out to the right while the new one slides in from
// the left with a rotateY flip.
// PresenceOne merges the animation classes onto a single style-forwarding child
// via cloneElement — no wrapper View. The cloned card is `absolute inset-0`, so
// the outgoing and incoming cards overlap inside the sized, clipped container and
// cross over each other during the swap.
function PresenceOneDemo(): ReactNode {
const [gameNumber, setGameNumber] = useState(1);
return (
Game {gameNumber}
);
}
// PresenceList animates each keyed child independently. Every item is an
// InfoMessage with its own dismiss button (Message's built-in onDismiss): adding
// fades + expands a new message in while the others stay put, dismissing one
// fades + collapses only that message out (animate-collapse-*, so the items below
// rise to fill the gap). Several items are present at once — a list, not a swap.
function PresenceListDemo(): ReactNode {
const nextIdRef = useRef(4);
const [ids, setIds] = useState([1, 2, 3]);
return (
{ids.map((id) => (
{
setIds((current) => current.filter((itemId) => itemId !== id));
}}
>
Item {id}
))}
);
}
const meta = {
title: "alouette/Containers/Presence",
parameters: {
componentSubtitle:
"Animate presence with pure CSS — PresenceOne swaps a single child, PresenceList animates add/remove across a keyed list.",
},
} satisfies Meta;
export default meta;
export const PreviewStory: StoryObj = {
name: "Presence Preview",
parameters: {
chromatic: { disableSnapshot: true },
layout: "padded",
},
render: () => ,
};
export const VariantsStory: StoryObj = {
name: "Presence Variants",
render: () => (
),
};
export const ReducedMotionStory: StoryObj = {
name: "Presence Reduced Motion",
parameters: { layout: "padded" },
render: () => (
),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Next game" }));
await expect(canvas.queryByText("Game 1")).toBeNull();
const card = canvas.getByText("Game 2").parentElement;
if (!card) throw new Error("Game card not found");
await expect(getComputedStyle(card).animationName).toBe("none");
await userEvent.click(
canvas.getByRole("button", { name: "Dismiss item 2" }),
);
await expect(canvas.queryByText("Item 2")).toBeNull();
await expect(canvas.getByText("Item 3")).toBeVisible();
},
};