import { expect, screen, userEvent, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { CalendarRegularIcon } from "alouette-icons/phosphor-icons/Calendar"; import { type ReactNode, useState } from "react"; import { Button } from "../actions/Button"; import { DecisionBar } from "../actions/DecisionBar"; import { Badge } from "../data/Badge"; import { Paragraph, Text } from "../primitives/Text"; import { View } from "../primitives/View"; import { Story, accentsWithoutNeutral } from "../story-components/Story"; import { EditableSection, type EditableSectionProps } from "./EditableSection"; import { Modal } from "./Modal"; type ThisStory = StoryObj; /** The body is plain JSX — as many blocks as the section needs. */ function EventSummary(): ReactNode { return ( An evening of readings in the old library. Before you come The gate closes at 19:00 — ring the bell after that. ); } /** * EditableSection owns no editor, so the stories supply one: `onEdit` opens a * plain Modal. FormEditableSection packages this same composition for a form. */ function EventSection({ title = "Event details", editAriaLabel = "Edit event details", titleBadge = 12 August 2026, children = , // The section brings no material of its own; most stories show it as a card. className = "surface", ...surfaceProps }: Partial): ReactNode { const [editing, setEditing] = useState(false); function close(): void { setEditing(false); } return ( { setEditing(true); }} > {children} } onDismiss={close} /> } onClose={close} > Your editor goes here. ); } export default { title: "alouette/Containers/EditableSection", component: EditableSection, parameters: { componentSubtitle: "A Surface holding a block of read-only content behind a single edit affordance — the multi-line counterpart of EditableItem.", }, argTypes: { title: { control: "text" }, details: { control: "text" }, className: { control: "text" }, editIconVariant: { control: "select", options: ["tonal", "filled", "outlined", "soft"], }, accent: { control: "select", options: accentsWithoutNeutral }, disabled: { control: "boolean" }, }, } satisfies Meta; export const EditableSectionPreviewStory: ThisStory = { name: "EditableSection Preview", parameters: { layout: "padded", chromatic: { disableSnapshot: true }, }, args: { title: "Event details" }, render: (args) => , }; export const EditableSectionVariantsStory: ThisStory = { name: "EditableSection Variants", render: () => ( Private} > Each guest only sees their own card. {/* The tightest case for the edit button's negative margin: a single-line title, so the row is shorter than the 38px button. */} One line of body. One line of body. } /> {accentsWithoutNeutral.map((accent) => ( ))} ), }; export const EditableSectionTestsStory: ThisStory = { name: "EditableSection Tests", render: () => ( {/* The size only moves the surface's padding — from 8px at surface-xxs to 48px at surface-lg — so the header must hold at either end. */} ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); // The title alone names the region — the badge beside it renders inside // the section without lending its text to the accessible name. const region = canvas.getByRole("region", { name: "Event details" }); await expect( within(region).getByText("An evening of readings in the old library."), ).toBeVisible(); // The badge is centered on the title's line box: a Badge's own // `self-start` would otherwise pin it to the top of the row. const badge = within(region).getByText("12 August 2026"); await expect(badge).toBeVisible(); const badgeRect = badge.getBoundingClientRect(); const titleRect = within(region) .getByText("Event details") .getBoundingClientRect(); await expect( Math.abs( badgeRect.top + badgeRect.height / 2 - (titleRect.top + titleRect.height / 2), ), ).toBeLessThanOrEqual(1); // A disabled section keeps its content readable and its button inert. await expect( canvas.getByRole("button", { name: "Edit locked section" }), ).toBeDisabled(); // The title is sized to the 38px edit button, so the header row leaves no // dead space around it and the button sits inside the surface's padding — // whatever that padding is. async function expectHeaderFitsTheButton(title: string): Promise { const surface = canvas.getByRole("region", { name: title }); const button = canvas.getByRole("button", { name: `Edit ${title}` }); const buttonRect = button.getBoundingClientRect(); const headingRect = within(surface) .getByText(title) .getBoundingClientRect(); const paddingTop = Number.parseFloat( getComputedStyle(surface).paddingTop, ); await expect(buttonRect.height).toBe(38); await expect( (buttonRect.height - headingRect.height) / 2, ).toBeLessThanOrEqual(4); await expect(buttonRect.top).toBeGreaterThanOrEqual( surface.getBoundingClientRect().top + paddingTop, ); } await expectHeaderFitsTheButton("surface-xxs"); await expectHeaderFitsTheButton("surface-xs"); await expectHeaderFitsTheButton("surface-sm"); await expectHeaderFitsTheButton("surface-md"); await expectHeaderFitsTheButton("surface-lg"); // The edit affordance is the button, not the surface: pressing the body // must not open anything. await userEvent.click(within(region).getByText("Before you come")); await expect(screen.queryAllByRole("dialog")).toHaveLength(0); // The modal portals outside the canvas, so it is queried from `screen`. // Presence, not visibility: the modal fades in from opacity 0, and // Chromatic pauses CSS animations at their first frame. await userEvent.click( canvas.getByRole("button", { name: "Edit event details" }), ); const dialog = await screen.findByRole("dialog", { name: "Event details" }); await expect( within(dialog).getByText("Your editor goes here."), ).toBeInTheDocument(); }, };