Files
gh-shaunrfox-okshaun-claude…/skills/panda-form-architecture.md
2025-11-30 08:56:16 +08:00

24 KiB

name, description
name description
panda-form-architecture Design and implement composable form component architectures using atomic design principles with Panda CSS

Panda CSS Form Architecture

When to Use This Skill

Use this skill when:

  • Building a form component system from scratch
  • Refactoring existing forms to use composable patterns
  • Creating form field wrappers for consistent accessibility and error handling
  • Implementing a design system's form components
  • Establishing form component hierarchy and composition patterns

For implementing individual form components (buttons, inputs), also reference panda-component-impl. For creating recipes for these components, use panda-recipe-patterns.

Form Component Composability Philosophy

Form components should follow a progressive composition model where simpler components combine into more complex ones. This atomic design approach creates:

  • Reusability: Foundational components work in multiple contexts
  • Consistency: Shared primitives ensure visual and behavioral uniformity
  • Flexibility: Compose components differently for different use cases
  • Maintainability: Changes to primitives cascade to all consumers

Three-Layer Architecture

Layer 1: Atomic Components (Primitives)

The foundational styled elements that map directly to HTML form controls.

Characteristics:

  • Single responsibility (one HTML element)
  • No internal composition
  • Accept Panda CSS style props for customization
  • Minimal logic (mostly styling)

Components:

<Box>        // Polymorphic base (any HTML element)
<Button>     // Styled <button> or <a>
<TextInput>  // Styled <input type="text">
<Textarea>   // Styled <textarea>
<CheckBox>   // Styled <input type="checkbox">
<Toggle>     // Styled <input type="checkbox"> for toggle switches
<Radio>      // Styled <input type="radio">
<Text>       // Styled text for labels, help text, errors
<Label>      // Styled <label> element

Example Implementation (Atomic):

Create: src/components/TextInput/TextInput.tsx

import { type FC, type InputHTMLAttributes } from 'react'
import { cx } from '@styled-system/css'
import { textInput, type TextInputVariantProps } from '@styled-system/recipes'
import { Box, type BoxProps } from '../Box/Box'
import { splitProps } from '~/utils/splitProps'

export type TextInputProps =
  Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> &
  BoxProps &
  TextInputVariantProps &
  {
    error?: boolean
  }

export const TextInput: FC<TextInputProps> = ({
  size,
  error = false,
  ...props
}) => {
  const [className, otherProps] = splitProps(props)

  return (
    <Box
      as="input"
      type="text"
      className={cx(textInput({ size }), className)}
      {...(error && { 'data-error': true })}
      aria-invalid={error}
      {...otherProps}
    />
  )
}

Key Points:

  • Simple wrapper around native input
  • Applies Panda CSS recipe for styling
  • Supports error state via data attribute
  • Includes ARIA attribute for accessibility
  • Allows style overrides via props

Layer 2: Molecular Components (Composed Primitives)

Combine atomic components for common patterns. These handle basic composition without complex logic.

Characteristics:

  • Combine 2-3 atomic components
  • Handle common use cases (input + label)
  • Still relatively simple
  • Improve ergonomics (less boilerplate for consumers)

Components:

<ToggleInput>    // Toggle + Label
<CheckboxInput>  // CheckBox + Label
<RadioInput>     // Radio + Label

Example Implementation (Molecular):

Create: src/components/CheckboxInput/CheckboxInput.tsx

import { type FC, type InputHTMLAttributes, type ReactNode } from 'react'
import { cx } from '@styled-system/css'
import { checkboxInput } from '@styled-system/recipes'
import { CheckBox, type CheckBoxProps } from '../CheckBox/CheckBox'
import { Box } from '../Box/Box'
import { Text } from '../Text/Text'

export type CheckboxInputProps =
  Omit<CheckBoxProps, 'label'> &
  {
    label?: ReactNode
    description?: string
  }

export const CheckboxInput: FC<CheckboxInputProps> = ({
  label,
  description,
  size,
  id,
  ...props
}) => {
  // Generate ID if not provided (for label/input association)
  const inputId = id || `checkbox-${Math.random().toString(36).substr(2, 9)}`

  const { container, labelText, descriptionText } = checkboxInput({ size })

  return (
    <Box as="label" htmlFor={inputId} className={container}>
      <CheckBox
        id={inputId}
        size={size}
        {...props}
      />

      {label && (
        <Box className={labelText}>
          {label}
        </Box>
      )}

      {description && (
        <Text className={descriptionText} color="gray.60">
          {description}
        </Text>
      )}
    </Box>
  )
}

Pattern Breakdown:

  1. Wraps CheckBox (atomic) with label and description
  2. Auto-generates ID for accessibility if not provided
  3. Uses slot recipe for layout styling
  4. Accepts ReactNode for flexible label content
  5. Optional description for additional context

When to Use Molecular vs Atomic:

  • Use atomic when you need maximum flexibility and custom layouts
  • Use molecular for standard form layouts (label beside/above input)
  • Both should be available in your component library

Layer 3: Organism Components (Complex Wrappers)

Higher-level components that provide structure, accessibility features, and common patterns like error handling.

Characteristics:

  • Orchestrate multiple components
  • Provide consistent patterns (labels, help text, errors)
  • Handle accessibility concerns (ARIA attributes, ID linking)
  • Accept children for maximum flexibility

Primary Component: FormField

FormField is a critical wrapper that provides:

  • Consistent label/input/error layout
  • Automatic accessibility (aria-describedby, aria-invalid)
  • Error and help text display
  • Required field indication

Example Implementation (Organism):

Create: src/components/FormField/FormField.tsx

import { type FC, type ReactNode, type ReactElement, cloneElement, isValidElement } from 'react'
import { formField } from '@styled-system/recipes'
import { Box } from '../Box/Box'
import { Label } from '../Label/Label'
import { Text } from '../Text/Text'

export type FormFieldProps = {
  label: string
  helpText?: string
  errorText?: string
  required?: boolean
  children: ReactNode
  htmlFor?: string
}

export const FormField: FC<FormFieldProps> = ({
  label,
  helpText,
  errorText,
  required = false,
  children,
  htmlFor,
}) => {
  // Generate IDs for accessibility linking
  const fieldId = htmlFor || `field-${Math.random().toString(36).substr(2, 9)}`
  const helpTextId = `${fieldId}-help`
  const errorTextId = `${fieldId}-error`

  const { container, labelSlot, helpTextSlot, errorTextSlot } = formField()

  // Clone children to inject ARIA attributes
  const enhancedChildren = isValidElement(children)
    ? cloneElement(children as ReactElement<any>, {
        id: fieldId,
        'aria-describedby': [
          helpText ? helpTextId : null,
          errorText ? errorTextId : null,
        ]
          .filter(Boolean)
          .join(' ') || undefined,
        'aria-invalid': !!errorText,
        'aria-required': required,
      })
    : children

  return (
    <Box className={container}>
      <Label htmlFor={fieldId} className={labelSlot}>
        {label}
        {required && (
          <Text as="span" color="red.50" aria-label="required">
            {' '}*
          </Text>
        )}
      </Label>

      {helpText && (
        <Text id={helpTextId} className={helpTextSlot} color="gray.60">
          {helpText}
        </Text>
      )}

      {enhancedChildren}

      {errorText && (
        <Text
          id={errorTextId}
          className={errorTextSlot}
          color="red.50"
          role="alert"
        >
          {errorText}
        </Text>
      )}
    </Box>
  )
}

Advanced Pattern Breakdown:

  1. Auto-generates IDs: Ensures proper label/input/error association
  2. Clones children: Injects ARIA attributes into child input
  3. aria-describedby: Links input to help text and errors
  4. aria-invalid: Marks input as invalid when error present
  5. aria-required: Indicates required fields to screen readers
  6. role="alert": Announces errors to screen readers immediately

Accessibility Features:

  • Proper label/input association via htmlFor and id
  • Help text linked via aria-describedby
  • Error text linked via aria-describedby and marked as role="alert"
  • Required fields indicated both visually (*) and semantically
  • Screen reader support through proper ARIA attributes

Full Form Implementation Example

Here's how all three layers compose into a complete, accessible form:

Create: src/pages/UserProfileForm.tsx

import { type FC, type FormEvent, useState } from 'react'
import { Box } from '~/components/Box/Box'
import { FormField } from '~/components/FormField/FormField'
import { TextInput } from '~/components/TextInput/TextInput'
import { Textarea } from '~/components/Textarea/Textarea'
import { RadioInput } from '~/components/RadioInput/RadioInput'
import { CheckboxInput } from '~/components/CheckboxInput/CheckboxInput'
import { Button } from '~/components/Button/Button'

export const UserProfileForm: FC = () => {
  const [errors, setErrors] = useState<Record<string, string>>({})

  const favColors = ['blue', 'red', 'yellow', 'green']

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()

    // Form validation logic here
    const formData = new FormData(e.currentTarget)

    // Example validation
    const firstName = formData.get('firstName') as string
    if (!firstName) {
      setErrors({ firstName: 'First name is required' })
      return
    }

    // Submit form...
  }

  return (
    <Box
      as="form"
      onSubmit={handleSubmit}
      display="flex"
      flexDirection="column"
      gap="24"
      maxWidth="600px"
      margin="0 auto"
      p="32"
    >
      <FormField
        label="First name"
        required
        errorText={errors.firstName}
        helpText="Enter your legal first name"
      >
        <TextInput
          name="firstName"
          placeholder="John"
          error={!!errors.firstName}
        />
      </FormField>

      <FormField
        label="Last name"
        required
        errorText={errors.lastName}
      >
        <TextInput
          name="lastName"
          placeholder="Doe"
          error={!!errors.lastName}
        />
      </FormField>

      <FormField
        label="Bio"
        helpText="Tell us about yourself (optional)"
      >
        <Textarea
          name="bio"
          placeholder="I'm a developer who loves..."
          rows={4}
        />
      </FormField>

      <FormField label="Favorite color" required>
        <Box display="flex" flexDirection="column" gap="12">
          {favColors.map((color) => (
            <RadioInput
              key={color}
              name="favoriteColor"
              value={color}
              label={color.charAt(0).toUpperCase() + color.slice(1)}
            />
          ))}
        </Box>
      </FormField>

      <CheckboxInput
        name="newsletter"
        label="Subscribe to newsletter"
        description="Receive updates about new features and releases"
      />

      <CheckboxInput
        name="terms"
        label="I agree to the terms and conditions"
        required
      />

      <Box display="flex" justifyContent="flex-end" gap="12">
        <Button type="button" variant="secondary">
          Cancel
        </Button>
        <Button type="submit" variant="primary">
          Save Profile
        </Button>
      </Box>
    </Box>
  )
}

Form Pattern Highlights:

  1. FormField wrapper: Consistent layout for all fields
  2. Error handling: Centralized error state, passed to FormField
  3. Help text: Contextual guidance for users
  4. Required indicators: Visual and semantic marking
  5. Accessible structure: Proper labels, ARIA attributes, semantic HTML
  6. Flexible composition: Mix atomic and molecular components as needed
  7. Layout control: Panda CSS props for spacing and arrangement

Recipe Architecture for Form Components

Atomic Component Recipes

Each atomic component should have its own recipe:

Create: src/styles/recipes/text-input.recipe.ts

import { defineRecipe } from '@pandacss/dev'

export const textInputRecipe = defineRecipe({
  className: 'textInput',
  description: 'Text input field styles',

  base: {
    width: 'full',
    px: '12',
    py: '8',
    fontSize: 'md',
    fontFamily: 'body',
    borderWidth: '1',
    borderColor: { base: 'gray.30', _dark: 'gray.70' },
    borderRadius: '6',
    bg: { base: 'white', _dark: 'slate.90' },
    color: { base: 'gray.90', _dark: 'gray.10' },
    transition: 'all 0.2s',

    _placeholder: {
      color: { base: 'gray.50', _dark: 'gray.60' },
    },

    _focus: {
      outline: 'none',
      borderColor: { base: 'blue.50', _dark: 'blue.40' },
      boxShadow: '0 0 0 3px token(colors.blue.20)',
    },

    _disabled: {
      opacity: 0.5,
      cursor: 'not-allowed',
      bg: { base: 'gray.10', _dark: 'gray.80' },
    },

    // Error state
    '&[data-error=true]': {
      borderColor: { base: 'red.50', _dark: 'red.40' },
      _focus: {
        boxShadow: '0 0 0 3px token(colors.red.20)',
      },
    },
  },

  variants: {
    size: {
      sm: {
        px: '8',
        py: '6',
        fontSize: 'sm',
      },
      md: {
        px: '12',
        py: '8',
        fontSize: 'md',
      },
      lg: {
        px: '16',
        py: '12',
        fontSize: 'lg',
      },
    },
  },

  defaultVariants: {
    size: 'md',
  },
})

Recipe Best Practices:

  • Use data attributes for custom states (data-error)
  • Include all interactive states (_focus, _disabled, _hover)
  • Support both light and dark themes
  • Use semantic token references
  • Provide size variants

Molecular Component Slot Recipes

Molecular components need layout coordination between primitives:

Create: src/styles/recipes/checkbox-input.recipe.ts

import { defineSlotRecipe } from '@pandacss/dev'

export const checkboxInputRecipe = defineSlotRecipe({
  className: 'checkboxInput',
  description: 'Checkbox with label composition',

  slots: ['container', 'labelText', 'descriptionText'],

  base: {
    container: {
      display: 'flex',
      alignItems: 'flex-start',
      gap: '12',
      cursor: 'pointer',

      _hover: {
        '& input': {
          borderColor: { base: 'blue.40', _dark: 'blue.50' },
        },
      },
    },

    labelText: {
      fontSize: 'md',
      fontWeight: 'medium',
      color: { base: 'gray.90', _dark: 'gray.10' },
      lineHeight: '1.5',
      userSelect: 'none',
    },

    descriptionText: {
      fontSize: 'sm',
      color: { base: 'gray.60', _dark: 'gray.50' },
      mt: '4',
    },
  },

  variants: {
    size: {
      sm: {
        container: { gap: '8' },
        labelText: { fontSize: 'sm' },
        descriptionText: { fontSize: 'xs' },
      },
      md: {
        container: { gap: '12' },
        labelText: { fontSize: 'md' },
        descriptionText: { fontSize: 'sm' },
      },
      lg: {
        container: { gap: '16' },
        labelText: { fontSize: 'lg' },
        descriptionText: { fontSize: 'md' },
      },
    },
  },

  defaultVariants: {
    size: 'md',
  },
})

Slot Recipe Pattern:

  • Define all component parts as slots
  • Coordinate sizing across slots with variants
  • Include hover states that affect children
  • Use userSelect: 'none' on labels for better UX

Organism Component Slot Recipes

FormField needs comprehensive slot management:

Create: src/styles/recipes/form-field.recipe.ts

import { defineSlotRecipe } from '@pandacss/dev'

export const formFieldRecipe = defineSlotRecipe({
  className: 'formField',
  description: 'Form field wrapper with label, help text, and error text',

  slots: ['container', 'labelSlot', 'helpTextSlot', 'errorTextSlot'],

  base: {
    container: {
      display: 'flex',
      flexDirection: 'column',
      gap: '8',
      width: 'full',
    },

    labelSlot: {
      fontSize: 'sm',
      fontWeight: 'semibold',
      color: { base: 'gray.90', _dark: 'gray.10' },
      mb: '4',
    },

    helpTextSlot: {
      fontSize: 'sm',
      color: { base: 'gray.60', _dark: 'gray.50' },
      mt: '4',
    },

    errorTextSlot: {
      fontSize: 'sm',
      fontWeight: 'medium',
      color: { base: 'red.60', _dark: 'red.40' },
      mt: '4',

      // Icon support
      display: 'flex',
      alignItems: 'center',
      gap: '6',
    },
  },
})

Best Practices Checklist

When building form architecture, create TodoWrite items for:

  • Create atomic components (TextInput, CheckBox, Radio, etc.)
  • Create recipes for atomic components with all variants
  • Implement molecular compositions (CheckboxInput, RadioInput, etc.)
  • Create slot recipes for molecular components
  • Build FormField organism with accessibility features
  • Create FormField slot recipe
  • Test all components with keyboard navigation
  • Test all components with screen reader
  • Verify ARIA attributes are properly applied
  • Test error states and error announcements
  • Verify required field indicators work
  • Test form submission and validation flow
  • Verify light and dark theme support
  • Test responsive behavior on mobile devices
  • Create Storybook stories for all form components
  • Document composition patterns and usage examples

Common Pitfalls

Avoid: Skipping the FormField Wrapper

// BAD: Manual label/error handling (inconsistent, inaccessible)
<div>
  <label htmlFor="email">Email</label>
  <TextInput id="email" />
  {error && <span style={{ color: 'red' }}>{error}</span>}
</div>

// GOOD: Use FormField for consistency
<FormField label="Email" errorText={error}>
  <TextInput />
</FormField>

Why: FormField ensures consistent accessibility, ARIA attributes, and visual design.

Avoid: Not Linking Help Text and Errors

// BAD: Screen readers can't connect help text to input
<Label>First name</Label>
<Text>Enter your legal name</Text>
<TextInput />

// GOOD: FormField automatically links via aria-describedby
<FormField label="First name" helpText="Enter your legal name">
  <TextInput />
</FormField>

Why: Proper ARIA linking helps screen reader users understand context.

Avoid: Over-composing Too Early

// BAD: Creating rigid mega-components
<MagicFormInput
  label="Email"
  type="email"
  helpText="..."
  errorText="..."
  icon="email"
  suffix="@company.com"
  tooltip="..."
/>

// GOOD: Compose flexibly from primitives
<FormField label="Email" helpText="..." errorText="...">
  <Box display="flex" alignItems="center">
    <Icon name="email" />
    <TextInput type="email" />
    <Text>@company.com</Text>
  </Box>
</FormField>

Why: Flexible composition beats rigid mega-components. Keep primitives simple, compose as needed.

Avoid: Inconsistent Error Handling

// BAD: Different error patterns across forms
<TextInput className={error ? 'error' : ''} />
<CheckBox style={{ borderColor: error ? 'red' : 'gray' }} />

// GOOD: Consistent error prop
<TextInput error={!!errors.email} />
<CheckBox error={!!errors.terms} />

Why: Consistent error APIs make forms predictable and maintainable.

Form Validation Integration

FormField works seamlessly with form libraries:

React Hook Form Example

import { useForm } from 'react-hook-form'

export const ProfileForm = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm()

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <FormField
        label="Email"
        required
        errorText={errors.email?.message}
      >
        <TextInput
          {...register('email', {
            required: 'Email is required',
            pattern: {
              value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
              message: 'Invalid email address',
            },
          })}
          error={!!errors.email}
        />
      </FormField>
    </form>
  )
}

Formik Example

import { Formik, Form } from 'formik'

export const ProfileForm = () => {
  return (
    <Formik
      initialValues={{ email: '' }}
      onSubmit={handleSubmit}
    >
      {({ errors, touched, values, handleChange }) => (
        <Form>
          <FormField
            label="Email"
            required
            errorText={touched.email && errors.email}
          >
            <TextInput
              name="email"
              value={values.email}
              onChange={handleChange}
              error={!!(touched.email && errors.email)}
            />
          </FormField>
        </Form>
      )}
    </Formik>
  )
}

Progressive Enhancement

Start simple, add complexity as needed:

Phase 1: Atomic components only

<Box as="form">
  <Label htmlFor="email">Email</Label>
  <TextInput id="email" name="email" />
  <Button type="submit">Submit</Button>
</Box>

Phase 2: Add molecular compositions

<Box as="form">
  <CheckboxInput name="terms" label="I agree" />
  <Button type="submit">Submit</Button>
</Box>

Phase 3: Add organism wrapper

<Box as="form">
  <FormField label="Email" helpText="...">
    <TextInput name="email" />
  </FormField>
  <Button type="submit">Submit</Button>
</Box>

Phase 4: Full validation and error handling

<Box as="form" onSubmit={handleSubmit}>
  <FormField
    label="Email"
    required
    helpText="We'll never share your email"
    errorText={errors.email}
  >
    <TextInput
      name="email"
      error={!!errors.email}
    />
  </FormField>
  <Button type="submit" loading={isSubmitting}>
    Submit
  </Button>
</Box>

Testing Form Components

Accessibility Testing

import { render, screen } from '@testing-library/react'
import { axe, toHaveNoViolations } from 'jest-axe'
import { FormField } from './FormField'
import { TextInput } from '../TextInput/TextInput'

expect.extend(toHaveNoViolations)

test('FormField has no accessibility violations', async () => {
  const { container } = render(
    <FormField label="Email" helpText="Enter your email">
      <TextInput />
    </FormField>
  )

  const results = await axe(container)
  expect(results).toHaveNoViolations()
})

test('FormField properly links label and input', () => {
  render(
    <FormField label="Email">
      <TextInput />
    </FormField>
  )

  const input = screen.getByLabelText('Email')
  expect(input).toBeInTheDocument()
})

test('FormField announces errors to screen readers', () => {
  render(
    <FormField label="Email" errorText="Email is required">
      <TextInput />
    </FormField>
  )

  const error = screen.getByRole('alert')
  expect(error).toHaveTextContent('Email is required')

  const input = screen.getByLabelText('Email')
  expect(input).toHaveAttribute('aria-invalid', 'true')
})

Summary

The form architecture follows a clear hierarchy:

Atomic → Molecular → Organism

  1. Atomic: Individual styled form controls (TextInput, CheckBox, Button)
  2. Molecular: Simple compositions (CheckboxInput = CheckBox + Label)
  3. Organism: Complex wrappers (FormField = Label + HelpText + Input + ErrorText + ARIA)

Key Principles:

  • Compose upward, never downward
  • Provide both atomic and molecular variants
  • Use FormField for consistent accessibility
  • Keep primitives simple and flexible
  • Test accessibility thoroughly
  • Integrate with form libraries as needed

This architecture ensures your forms are accessible, maintainable, and consistent across your application.