import React from "react"; import { StoryObj, Meta } from "@storybook/react-vite"; import { within, expect, userEvent, fn } from "storybook/test"; import { useState } from "react"; import Form from "./form"; import "./form.scss"; // Type assertion to resolve React type compatibility issues const FormComponent = Form as unknown as typeof Form; const meta: Meta = { title: "FP.React Forms/Form", tags: ["stable", "autodocs"], component: FormComponent, parameters: { docs: { description: { component: ` An accessible HTML form wrapper with validation support and compound component pattern. Provides proper ARIA attributes, form submission handling, and validation state management. ## Features - ✅ WCAG 2.1 AA compliant with proper ARIA attributes - ✅ Compound component pattern (Form.Field, Form.Input, etc.) - ✅ Form submission and validation state management - ✅ Keyboard navigation support - ✅ Controlled and uncontrolled form patterns `, }, }, }, args: { name: "contact-form", "aria-label": "Contact form", }, } as Meta; export default meta; type Story = StoryObj; // Mock submit handler for stories const handleSubmit = fn(); /** * Basic form example with required fields */ export const BasicForm: Story = { args: { onSubmit: handleSubmit, children: ( <> ), }, play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); await step("Form renders correctly", async () => { const form = canvas.getByRole("form"); expect(form).toBeInTheDocument(); }); await step("Required fields are marked", async () => { expect( canvas.getByText("*", { selector: ".field-required" }) ).toBeInTheDocument(); }); await step("All inputs are accessible", async () => { expect(canvas.getByLabelText(/name/i)).toBeInTheDocument(); expect(canvas.getByLabelText(/email/i)).toBeInTheDocument(); expect(canvas.getByLabelText(/message/i)).toBeInTheDocument(); }); await step("Submit button is present", async () => { expect( canvas.getByRole("button", { name: /submit/i }) ).toBeInTheDocument(); }); }, } as Story; /** * Form with validation states and error messages */ export const WithValidation: Story = { render: function ValidationExample() { const [email, setEmail] = useState(""); const [emailError, setEmailError] = useState(""); const validateEmail = (value: string) => { if (!value) { setEmailError("Email is required"); } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { setEmailError("Please enter a valid email address"); } else { setEmailError(""); } }; return ( { e.preventDefault(); handleSubmit(e); }} > setEmail(e.target.value)} onBlur={(e) => validateEmail(e.target.value)} validationState={emailError ? "invalid" : email ? "valid" : "none"} required /> ); }, play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); const user = userEvent.setup(); await step("Enter invalid email", async () => { const emailInput = canvas.getByLabelText(/email/i); await user.type(emailInput, "invalid-email"); await user.tab(); }); await step("Error message is displayed", async () => { // findByText waits for the async validation state to propagate from // the onBlur handler into the DOM; getByText here was asserting too // soon and flaking under the test-runner's tight timing. await expect( canvas.findByText(/please enter a valid email/i) ).resolves.toBeInTheDocument(); }); await step("Input has aria-invalid", async () => { const emailInput = canvas.getByLabelText(/email/i); expect(emailInput).toHaveAttribute("aria-invalid", "true"); }); }, }; /** * Form with hint text to guide users */ export const WithHintText: Story = { args: { "aria-label": "Account creation form", children: ( <> ), }, play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); await step("Hint text is visible", async () => { expect(canvas.getByText(/must be 3-20 characters/i)).toBeInTheDocument(); expect(canvas.getByText(/minimum 8 characters/i)).toBeInTheDocument(); }); await step("Hint text is associated with inputs", async () => { const usernameInput = canvas.getByLabelText(/username/i); expect(usernameInput).toHaveAttribute( "aria-describedby", "username-hint" ); }); }, }; /** * Form with select dropdown */ export const WithSelect: Story = { args: { "aria-label": "Profile form", children: ( <> Select a country United States Canada United Kingdom Australia ), }, play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); const user = userEvent.setup(); await step("Select is accessible", async () => { const select = canvas.getByLabelText(/country/i); expect(select).toBeInTheDocument(); }); await step("Select can be changed", async () => { const select = canvas.getByLabelText(/country/i); await user.selectOptions(select, "us"); expect(select).toHaveValue("us"); }); }, }; /** * Form with optional fields */ export const WithOptionalFields: Story = { args: { "aria-label": "Contact preferences", children: ( <> ), }, play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); await step("Required field is marked with asterisk", async () => { expect( canvas.getByText("*", { selector: ".field-required" }) ).toBeInTheDocument(); }); await step("Optional fields are marked", async () => { const optionalMarkers = canvas.getAllByText("(optional)"); expect(optionalMarkers).toHaveLength(2); }); }, }; /** * Form submission loading state */ export const LoadingState: Story = { render: function LoadingStateExample() { const [isSubmitting, setIsSubmitting] = useState(false); const handleFormSubmit = (e: React.FormEvent) => { e.preventDefault(); setIsSubmitting(true); setTimeout(() => setIsSubmitting(false), 2000); }; return ( ); }, play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); const user = userEvent.setup(); await step("Submit the form", async () => { const submitButton = canvas.getByRole("button"); await user.click(submitButton); }); await step("Form shows loading state", async () => { const form = canvas.getByRole("form"); expect(form).toHaveAttribute("aria-busy", "true"); expect(form).toHaveAttribute("data-status", "submitting"); }); await step("Submit button shows loading text", async () => { expect(canvas.getByText(/submitting/i)).toBeInTheDocument(); }); }, }; /** * Complete registration form example */ export const RegistrationForm: Story = { args: { name: "registration", "aria-labelledby": "registration-heading", children: ( <>

Create Your Account

Choose your country United States Canada United Kingdom ), }, play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); await step("All form fields render", async () => { expect(canvas.getByLabelText(/email address/i)).toBeInTheDocument(); expect(canvas.getByLabelText(/password/i)).toBeInTheDocument(); expect(canvas.getByLabelText(/country/i)).toBeInTheDocument(); expect(canvas.getByLabelText(/bio/i)).toBeInTheDocument(); }); await step("Form is properly labeled", async () => { const form = canvas.getByRole("form"); expect(form).toHaveAttribute("aria-labelledby", "registration-heading"); }); }, }; /** * Form with onEnter accessibility handlers * Demonstrates the onEnter prop for keyboard-driven workflows */ export const WithOnEnterHandler: Story = { render: function OnEnterHandlerExample() { const [searchQuery, setSearchQuery] = useState(""); const [comments, setComments] = useState(""); const [category, setCategory] = useState(""); const [messages, setMessages] = useState([]); const handleSearch = () => { setMessages((prev) => [...prev, `🔍 Searching for: "${searchQuery}"`]); }; const handleCommentSubmit = () => { if (comments.trim()) { setMessages((prev) => [ ...prev, `💬 Comment submitted: "${comments.trim()}"`, ]); setComments(""); } }; const handleCategorySelect = () => { setMessages((prev) => [...prev, `📁 Category selected: "${category}"`]); }; return (
) => setSearchQuery(e.target.value) } onEnter={handleSearch} /> ) => setComments(e.target.value) } onEnter={handleCommentSubmit} rows={4} /> ) => setCategory(e.target.value) } onEnter={handleCategorySelect} > Select category Bug Report Feature Request Question {messages.length > 0 && (

Action Log:

    {messages.map((msg, idx) => (
  • {msg}
  • ))}
)}
); }, play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); const user = userEvent.setup(); await step("Input onEnter: Type and press Enter", async () => { const searchInput = canvas.getByLabelText(/search/i); await user.type(searchInput, "accessibility test"); await user.type(searchInput, "{Enter}"); // Verify action was logged expect( canvas.getByText(/Searching for: "accessibility test"/i) ).toBeInTheDocument(); }); await step("Textarea onEnter: Enter without Shift", async () => { const textarea = canvas.getByLabelText(/comments/i); await user.type(textarea, "This is a test comment"); await user.type(textarea, "{Enter}"); // Verify comment was submitted expect( canvas.getByText(/Comment submitted: "This is a test comment"/i) ).toBeInTheDocument(); }); await step( "Textarea Shift+Enter: Adds newline without triggering onEnter", async () => { const textarea = canvas.getByLabelText(/comments/i); await user.type(textarea, "Line 1{Shift>}{Enter}{/Shift}Line 2"); // Verify textarea contains newline expect(textarea).toHaveValue("Line 1\nLine 2"); } ); await step("Select onEnter: Select and press Enter", async () => { const select = canvas.getByLabelText(/category/i); await user.selectOptions(select, "bug"); await user.type(select, "{Enter}"); // Verify category selection was logged expect(canvas.getByText(/Category selected: "bug"/i)).toBeInTheDocument(); }); }, }; /** * CSS Variable Customization * * Demonstrates how to customize form/input appearance using the new standardized * CSS custom property naming convention. * * New variable naming patterns: * - Logical properties: `--input-padding-inline`, `--input-padding-block` * - Full property names: `--input-width`, `--input-radius`, `--input-border` * - Approved abbreviations: `--input-fs` (font-size), `--input-bg` (background) * - State variables: `--input-focus-outline`, `--input-disabled-bg` */ export const Customization: Story = { render: () => (
{/* Custom input styling */}

Custom Input Styling

{/* Compact form */}

Compact Form

{/* Custom focus states */}

Custom Focus States

{/* Custom textarea */}

Custom Textarea

{/* Custom select */}

Custom Select

Select country United States Canada United Kingdom
{/* Dark theme example */}

Dark Theme Example

{/* Disabled state customization */}

Custom Disabled State

), parameters: { docs: { description: { story: ` ## Available CSS Variables ### Base Properties - \`--input-padding-inline\`: Horizontal padding (logical property) - \`--input-padding-block\`: Vertical padding (logical property) - \`--input-width\`: Input width - \`--input-radius\`: Border radius - \`--input-color\`: Text color - \`--input-bg\`: Background color - \`--input-border\`: Border style ### Typography (Approved Abbreviations) - \`--input-fs\`: Font size ### Focus State Variables - \`--input-focus-outline\`: Outline on focus - \`--input-focus-outline-offset\`: Outline offset on focus ### Disabled State Variables - \`--input-disabled-bg\`: Background color when disabled - \`--input-disabled-opacity\`: Opacity when disabled ### Placeholder Variables - \`--placeholder-fs\`: Placeholder font size - \`--placeholder-color\`: Placeholder text color ### Migration from Old Names - ❌ \`--input-px\` → ✅ \`--input-padding-inline\` - ❌ \`--input-py\` → ✅ \`--input-padding-block\` - ❌ \`--input-w\` → ✅ \`--input-width\` ## Usage Examples ### Custom Input \`\`\`tsx \`\`\` ### Custom Focus State \`\`\`tsx \`\`\` ### Dark Theme Input \`\`\`tsx \`\`\` `, }, }, }, } as Story;