import { expect, userEvent, waitFor, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { type ReactNode, useState } from "react"; import { type Control, useFormContext } from "react-hook-form"; import { ConfirmationMessage } from "../feedback/Message"; import { InputText } from "../inputs/InputText"; import { Story } from "../story-components/Story"; import { FormValidationError } from "./Form"; import { FormField } from "./FormField"; import { SimpleVForm } from "./SimpleVForm"; function submitErrorToMessage(error: unknown): string { if (error instanceof FormValidationError) { return "Please fix the errors above."; } return error instanceof Error ? error.message : "Something went wrong."; } interface FormValues { name: string; email: string; age: string; } function validateName(value: string): string | undefined { if (value.trim().length === 0) return "Name is required."; if (value.trim().length < 2) return "Name must be at least 2 characters."; return undefined; } const emailPattern = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/; function validateEmail(value: string): string | undefined { if (value.trim().length === 0) return "Email is required."; if (!emailPattern.test(value)) return "Enter a valid email address."; return undefined; } function validateAge(value: string): string | undefined { if (value.trim().length === 0) return "Age is required."; if (!/^\d+$/.test(value)) return "Age must be a whole number."; const age = Number(value); if (age < 0 || age > 120) return "Age must be between 0 and 120."; return undefined; } interface SimpleVFormFieldsProps { control: Control; submit: () => Promise; } function SimpleVFormFields({ control, submit, }: SimpleVFormFieldsProps): ReactNode { // setFocus lives on the form instance, not on control, so moving focus // between fields is the one thing still read from context. const { setFocus } = useFormContext(); return ( <> ( { setFocus("email"); }} /> )} /> ( { setFocus("age"); }} /> )} /> ( { submit().catch(() => {}); }} /> )} /> ); } function SimpleVFormDemo(): ReactNode { const [submitted, setSubmitted] = useState(null); return ( <> defaultValues={{ name: "", email: "", age: "" }} submitLabel="Submit" submitErrorToMessage={submitErrorToMessage} render={({ control, submit }) => ( )} onSubmit={(value) => { setSubmitted(value); }} /> {submitted ? ( Submitted {submitted.name} ({submitted.email}), age {submitted.age} ) : null} ); } type ThisStory = StoryObj; export default { title: "alouette/Forms/SimpleVForm", component: SimpleVFormDemo, parameters: { componentSubtitle: "Standardizes the common case: a Form laid out as a vertical stack of FormFields with a trailing FormSubmitButton.", }, } satisfies Meta; export const SimpleVFormStory: ThisStory = { name: "SimpleVForm examples", render: () => ( ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const nameInput = canvas.getByLabelText("Name"); const emailInput = canvas.getByLabelText("Email"); const ageInput = canvas.getByLabelText("Age"); // Invalid values are rejected and reported inline. await userEvent.type(nameInput, "A"); await userEvent.tab(); await expect( canvas.getByText("Name must be at least 2 characters."), ).toBeVisible(); await userEvent.clear(nameInput); await userEvent.type(nameInput, "Ada Lovelace"); await userEvent.type(emailInput, "not-an-email"); await userEvent.type(ageInput, "12"); await expect( canvas.getByText("Enter a valid email address."), ).toBeVisible(); // Fix the email, then submit via Enter on the last field. await userEvent.clear(emailInput); await userEvent.type(emailInput, "ada@example.com{enter}"); await waitFor(async () => { await expect(ageInput).toHaveFocus(); }); await userEvent.type(ageInput, "{enter}"); await expect( canvas.getByText("Submitted Ada Lovelace (ada@example.com), age 12"), ).toBeVisible(); // Pressing a label still focuses its input via react-hook-form's setFocus. await userEvent.click(canvas.getByText("Name")); await expect(nameInput).toHaveFocus(); }, };