Back to list
Mavrick91

forms

by Mavrick91

0🍴 0📅 Jan 10, 2026

SKILL.md


name: forms description: Build forms with FNForm component including validation, grid layouts, custom fields, and external control. Use when creating forms, adding validation, or building complex form UIs.

Form Builder

Create forms using this project's centralized FNForm component.

Quick Start

import { FNForm, type FormDefinition } from '@/components/ui/fn-form'

const formDefinition: FormDefinition = {
  fields: [
    { name: 'email', type: 'email', label: 'Email', required: true },
    { name: 'name', type: 'text', label: 'Name' },
  ],
}

function MyForm() {
  const handleSubmit = (values: Record<string, unknown>) => {
    console.log(values)
  }

  return (
    <FNForm
      formDefinition={formDefinition}
      onSubmit={handleSubmit}
      submitButtonText="Save"
    />
  )
}

Field Types

TypeComponentUse Case
textInputSingle-line text
emailInput type="email"Email addresses
passwordInput type="password"Passwords
numberInput type="number"Numeric values
textareaTextareaMulti-line text
selectSelect dropdownChoose from options
checkboxCheckboxBoolean with inline label
switchSwitchToggle with inline label
hiddenNone (hidden)Hidden values
customYour componentAnything else

Field Definition

interface FieldDefinition {
  name: string // Form field name (required)
  type: FieldType // Input type (required)
  label: string // Display label (required)
  placeholder?: string // Placeholder text
  required?: boolean // Shows * and validates
  optional?: boolean // Shows "(optional)"
  disabled?: boolean // Disable input
  options?: SelectOption[] // For select type
  validate?: (value: unknown) => string | undefined
  validateOnChange?: boolean // Validate as user types
  className?: string // Wrapper class
  inputClassName?: string // Input class
  labelClassName?: string // Label class
  prefix?: string // Input prefix (e.g., "$")
  maxLength?: number // Shows character count
  render?: (props: CustomFieldRenderProps) => ReactNode
}

Grid Layouts

Use rows with columns for multi-column forms:

const formDefinition: FormDefinition = {
  rows: [
    // Full width row
    {
      fields: [
        { name: 'email', type: 'email', label: 'Email', required: true },
      ],
    },

    // Two column row
    {
      columns: 2,
      fields: [
        {
          name: 'firstName',
          type: 'text',
          label: 'First Name',
          required: true,
        },
        { name: 'lastName', type: 'text', label: 'Last Name', required: true },
      ],
    },

    // Three column row
    {
      columns: 3,
      fields: [
        { name: 'city', type: 'text', label: 'City', required: true },
        { name: 'state', type: 'text', label: 'State' },
        { name: 'zip', type: 'text', label: 'ZIP', required: true },
      ],
    },
  ],
}

Validation

Required Fields

{ name: 'email', type: 'email', label: 'Email', required: true }
// Shows * after label, validates on submit

Custom Validation

{
  name: 'email',
  type: 'email',
  label: 'Email',
  required: true,
  validate: (value) => {
    const email = value as string
    if (!email.includes('@company.com')) {
      return 'Must be a company email'
    }
    return undefined  // No error
  },
  validateOnChange: true,  // Validate as user types
}

Common Validators

// Email format
validate: (value) => {
  const email = value as string
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return 'Invalid email format'
  }
}

// Min length
validate: (value) => {
  if ((value as string).length < 8) {
    return 'Must be at least 8 characters'
  }
}

// Number range
validate: (value) => {
  const num = Number(value)
  if (num < 0 || num > 100) {
    return 'Must be between 0 and 100'
  }
}

// Match another field (password confirmation)
validate: (value, allValues) => {
  if (value !== allValues.password) {
    return 'Passwords do not match'
  }
}

Select Dropdown

{
  name: 'status',
  type: 'select',
  label: 'Status',
  placeholder: 'Select status',
  required: true,
  options: [
    { value: 'draft', label: 'Draft' },
    { value: 'active', label: 'Active' },
    { value: 'archived', label: 'Archived' },
  ],
}

Custom Fields

For masked inputs, autocomplete, date pickers, or any special component:

import { IMaskInput } from 'react-imask'

{
  name: 'phone',
  type: 'custom',
  label: 'Phone',
  render: (props) => (
    <IMaskInput
      id={props.id}
      mask="+{1} (000) 000-0000"
      value={String(props.value ?? '')}
      onAccept={(value) => props.onChange(value)}
      onBlur={props.onBlur}
      disabled={props.disabled}
      placeholder={props.placeholder}
      className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
    />
  ),
}

Custom Field Props

interface CustomFieldRenderProps {
  value: unknown // Current field value
  onChange: (value: unknown) => void // Update value
  onBlur: () => void // Trigger blur validation
  error?: string // Current error message
  id: string // Field ID for labels
  disabled?: boolean // Disabled state
  placeholder?: string // Placeholder text
}

Prefix Input

{
  name: 'price',
  type: 'number',
  label: 'Price',
  prefix: '$',  // Shows "$" inside the input
  required: true,
}

Character Counter

{
  name: 'description',
  type: 'textarea',
  label: 'Description',
  maxLength: 500,  // Shows "0 / 500 characters"
}

External Form Control

Use formRef to control the form from outside:

import { useRef } from 'react'
import { FNForm, type FNFormRef } from '@/components/ui/fn-form'

function MyForm() {
  const formRef = useRef<FNFormRef | null>(null)

  return (
    <div>
      <FNForm
        formRef={formRef}
        hideSubmitButton  // We'll use our own button
        formDefinition={formDefinition}
        onSubmit={handleSubmit}
      />

      {/* External submit button */}
      <Button onClick={() => formRef.current?.submit()}>
        Save
      </Button>

      {/* External value access */}
      <Button onClick={() => {
        const values = formRef.current?.getValues()
        console.log(values)
      }}>
        Log Values
      </Button>
    </div>
  )
}

FormRef Methods

interface FNFormRef {
  submit: () => void // Trigger form submission
  setFieldValue: (name: string, value: unknown) => void // Set a field value
  getFieldValue: (name: string) => unknown // Get a field value
  setFieldError: (name: string, error: string) => void // Set a field error
  getValues: () => Record<string, unknown> // Get all values
  isSubmitting: boolean // Submission state
}

Field Change Callbacks

React to field changes and update other fields:

<FNForm
  formDefinition={formDefinition}
  onSubmit={handleSubmit}
  onFieldChange={(name, value, setFieldValue) => {
    // When country changes, update country code
    if (name === 'country') {
      const countryCode = getCountryCode(value as string)
      setFieldValue('countryCode', countryCode)
    }

    // When "same as billing" is checked, copy address
    if (name === 'sameAsBilling' && value === true) {
      setFieldValue('shippingAddress', billingAddress)
    }
  }}
/>

Custom Submit Button

<FNForm
  formDefinition={formDefinition}
  onSubmit={handleSubmit}
  renderSubmitButton={(isSubmitting) => (
    <Button type="submit" disabled={isSubmitting} className="w-full">
      {isSubmitting ? (
        <>
          <Loader2 className="mr-2 h-4 w-4 animate-spin" />
          Saving...
        </>
      ) : (
        'Save Changes'
      )}
    </Button>
  )}
/>

Before Submit Content

Add content above the submit button:

<FNForm
  formDefinition={formDefinition}
  onSubmit={handleSubmit}
  renderBeforeSubmit={(values) => (
    <div className="rounded-lg bg-muted p-4">
      <p className="text-sm text-muted-foreground">
        Total: ${calculateTotal(values)}
      </p>
    </div>
  )}
/>

Complete Examples

Address Form

const addressForm: FormDefinition = {
  rows: [
    {
      columns: 2,
      fields: [
        {
          name: 'firstName',
          type: 'text',
          label: 'First Name',
          required: true,
        },
        { name: 'lastName', type: 'text', label: 'Last Name', required: true },
      ],
    },
    {
      fields: [
        { name: 'company', type: 'text', label: 'Company', optional: true },
      ],
    },
    {
      fields: [
        { name: 'address1', type: 'text', label: 'Address', required: true },
      ],
    },
    {
      fields: [
        {
          name: 'address2',
          type: 'text',
          label: 'Apartment, suite, etc.',
          optional: true,
        },
      ],
    },
    {
      columns: 3,
      fields: [
        { name: 'city', type: 'text', label: 'City', required: true },
        {
          name: 'province',
          type: 'text',
          label: 'State/Province',
          required: true,
        },
        { name: 'zip', type: 'text', label: 'ZIP/Postal', required: true },
      ],
    },
    {
      columns: 2,
      fields: [
        {
          name: 'country',
          type: 'select',
          label: 'Country',
          required: true,
          options: [
            { value: 'US', label: 'United States' },
            { value: 'CA', label: 'Canada' },
            { value: 'GB', label: 'United Kingdom' },
          ],
        },
        { name: 'phone', type: 'text', label: 'Phone', optional: true },
      ],
    },
  ],
}

Login Form

const loginForm: FormDefinition = {
  fields: [
    {
      name: 'email',
      type: 'email',
      label: 'Email',
      required: true,
      placeholder: 'you@example.com',
    },
    {
      name: 'password',
      type: 'password',
      label: 'Password',
      required: true,
    },
    {
      name: 'remember',
      type: 'checkbox',
      label: 'Remember me',
    },
  ],
}

Product Form

const productForm: FormDefinition = {
  rows: [
    {
      fields: [
        {
          name: 'name',
          type: 'text',
          label: 'Product Name',
          required: true,
          maxLength: 100,
        },
      ],
    },
    {
      fields: [
        {
          name: 'description',
          type: 'textarea',
          label: 'Description',
          maxLength: 500,
        },
      ],
    },
    {
      columns: 2,
      fields: [
        {
          name: 'price',
          type: 'number',
          label: 'Price',
          prefix: '$',
          required: true,
          validate: (v) =>
            Number(v) < 0 ? 'Price cannot be negative' : undefined,
        },
        {
          name: 'compareAtPrice',
          type: 'number',
          label: 'Compare at Price',
          prefix: '$',
          optional: true,
        },
      ],
    },
    {
      columns: 2,
      fields: [
        {
          name: 'status',
          type: 'select',
          label: 'Status',
          required: true,
          options: [
            { value: 'draft', label: 'Draft' },
            { value: 'active', label: 'Active' },
          ],
        },
        {
          name: 'quantity',
          type: 'number',
          label: 'Quantity',
          required: true,
        },
      ],
    },
  ],
}

FNForm Props

interface FNFormProps {
  formDefinition: FormDefinition
  onSubmit: (values: Record<string, unknown>) => void | Promise<void>
  defaultValues?: Record<string, unknown>
  submitButtonText?: string
  hideSubmitButton?: boolean
  formRef?: React.RefObject<FNFormRef | null>
  onFieldChange?: (
    name: string,
    value: unknown,
    setFieldValue: (name: string, value: unknown) => void,
  ) => void
  className?: string
  renderSubmitButton?: (isSubmitting: boolean) => React.ReactNode
  renderBeforeSubmit?: (values: Record<string, unknown>) => React.ReactNode
}

See Also

  • src/components/ui/fn-form.tsx - Component source
  • src/routes/$lang/account/addresses.tsx - Address form example
  • src/routes/admin/login.tsx - Login form example
  • admin-crud skill - Forms in admin context

Score

Total Score

50/100

Based on repository quality metrics

SKILL.md

SKILL.mdファイルが含まれている

+20
LICENSE

ライセンスが設定されている

0/10
説明文

100文字以上の説明がある

0/10
人気

GitHub Stars 100以上

0/15
最近の活動

3ヶ月以内に更新がある

0/10
フォーク

10回以上フォークされている

0/5
Issue管理

オープンIssueが50未満

+5
言語

プログラミング言語が設定されている

+5
タグ

1つ以上のタグが設定されている

0/5

Reviews

💬

Reviews coming soon