← Dashboard
Zubeyr··Componentsformscrm

Record Form

A schema-driven CRM form built on react-hook-form: describe sections and fields as data and get accordion, tab, card or step-by-step layouts, an edit mode and a click-to-edit view mode, 17 field types including people pickers, files, time, month, lists and editable tables with totals, calculated fields, show/hide and required-when conditions, validation rules through one resolver that also takes Zod, Valibot or ArkType, uploads with progress and retry, role permissions, drafts, autosave, a leave warning, translations and dayjs-powered locale dates.

Live demo ↗

Installation

Run the shadcn CLI in a project that already has components.json. Using the @tomei tab? Register the namespace once first.

pnpm dlx shadcn@latest add https://tomei.zubeyr.dev/ui/r/record-form.json
  • →Installs dayjs from npm.
  • →Installs react-hook-form from npm.
  • →Installs react-dropzone from npm.
  • →Pulls in input.
  • →Pulls in date-picker.
  • →Pulls in toast.
  • →Pulls in popover.
  • →Pulls in dialog.
  • →Files land in components/dashboard/record-form.

Installing by hand? Add the dependencies, then copy the source code below.

terminal.sh
pnpm add dayjs react-hook-form react-dropzone

Usage

example.tsx
'use client'

import 'dayjs/locale/fr'

import { useMemo, useState } from 'react'
import { Building2, ClipboardList, Layers, Package, Paperclip, Wallet } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'

import { cn } from '@/lib/utils'
import { toast, Toaster } from '@/registry/dashboard/components/toast/toast'
import { Button } from '@/registry/dashboard/components/button/button'

import {
  applyComputed,
  ChangeList,
  RecordForm,
  recordFormResolver,
  useFormChanges,
  type FormLayout,
  type FormSection,
  type FormValues,
  type Messages,
  type OptionObject,
  type Row,
  type Uploader,
} from '@/components/dashboard/record-form/record-form'

const money = (n: number) => Math.round(n * 100) / 100
const lineTotal = (rows: unknown) =>
  (Array.isArray(rows) ? (rows as Row[]) : []).reduce(
    (s, r) => s + (Number(r.qty) || 0) * (Number(r.cost) || 0),
    0,
  )

const COMPANIES = [
  'Abay',
  'Bole',
  'Entoto',
  'Gihon',
  'Harar',
  'Lalibela',
  'Rift',
  'Sheba',
  'Tana',
  'Zeway',
]
const KINDS = ['Construction', 'Trading', 'Real Estate', 'Engineering', 'Holdings', 'Logistics']
const CITIES = ['Addis Ababa', 'Adama', 'Bahir Dar', 'Hawassa', 'Mekelle', 'Dire Dawa']
const customers: OptionObject[] = Array.from({ length: 10000 }, (_, i) => ({
  value: `cus-${i + 1}`,
  label: `${COMPANIES[i % 10]} ${KINDS[Math.floor(i / 10) % 6]} ${Math.floor(i / 60) + 1}`,
  description: `${CITIES[i % 6]} · C-${String(i + 1).padStart(5, '0')}`,
  avatar: COMPANIES[i % 10][0] + KINDS[Math.floor(i / 10) % 6][0],
}))
const people: OptionObject[] = [
  ['Hana Solomon', 'Finance officer'],
  ['Dawit Mekonnen', 'Site supervisor'],
  ['Selam Girma', 'Architect'],
  ['Amanuel Girma', 'Site supervisor'],
  ['Naol Tesfaye', 'Commercial'],
  ['Yodit Alemayehu', 'Project coordinator'],
].map(([label, description], i) => ({
  value: `emp-${i + 1}`,
  label,
  description,
  avatar: label
    .split(' ')
    .map((w) => w[0])
    .join(''),
}))
const addresses = [
  'Bole Road, Addis Ababa',
  'Bole Medhanialem, Addis Ababa',
  'Kazanchis, Addis Ababa',
  'Piassa, Addis Ababa',
  'CMC Road, Addis Ababa',
  'Adama Industrial Park, Adama',
  'Kebele 14, Bahir Dar',
  'Piazza, Hawassa',
]

const fakeServer = <T,>(result: () => T, ms = 350) =>
  new Promise<T>((resolve) => setTimeout(() => resolve(result()), ms))

const searchCustomers = (q: string) =>
  fakeServer(() => {
    const t = q.trim().toLowerCase()
    return customers
      .filter(
        (c) => !t || c.label.toLowerCase().includes(t) || c.description!.toLowerCase().includes(t),
      )
      .slice(0, 20)
  })
const searchPeople = (q: string) =>
  fakeServer(
    () => people.filter((p) => p.label.toLowerCase().includes(q.trim().toLowerCase())),
    150,
  )
const searchAddresses = (q: string) =>
  fakeServer(() =>
    addresses
      .filter((a) => a.toLowerCase().includes(q.trim().toLowerCase()))
      .map((a) => ({ value: a, label: a })),
  )

const sections: FormSection[] = [
  {
    id: 'client',
    title: 'Client & contract',
    icon: <Building2 />,
    columns: 3,
    span: 'full',
    fields: [
      {
        name: 'customer',
        label: 'Customer',
        type: 'lookup',
        required: true,
        search: searchCustomers,
        options: [customers[41]],
        hint: '10,000 customers, searched on the server',
      },
      {
        name: 'team',
        label: 'Project team',
        type: 'lookup',
        multiple: true,
        search: searchPeople,
        options: people,
      },
      { name: 'phone', label: 'Site phone', type: 'phone', defaultCountry: 'ET' },
      { name: 'site', label: 'Site address', type: 'address', search: searchAddresses, span: 2 },
      { name: 'period', label: 'Contract period', type: 'daterange', required: true },
      { name: 'inspection', label: 'First inspection', type: 'datetime', step: 30 },
      {
        name: 'value',
        label: 'Contract value',
        type: 'currency',
        decimals: 2,
        min: 0,
        permissions: { edit: ['finance', 'admin'] },
        hint: 'Only Finance and Admin can change this',
      },
      { name: 'retention', label: 'Retention', type: 'percent', min: 0, max: 20, decimals: 1 },
      {
        name: 'effort',
        label: 'Estimated effort',
        type: 'duration',
        max: 60 * 24 * 30,
        hint: 'Try “2d 4h”, “90m” or “1:30”',
      },
      { name: 'scope', label: 'Scope of work', type: 'richtext', maxLength: 2000 },
      {
        name: 'signedBy',
        label: 'Client signature',
        type: 'signature',
        permissions: { view: ['finance', 'admin'] },
      },
    ],
  },
  {
    id: 'request',
    title: 'Request',
    icon: <ClipboardList />,
    columns: 3,
    span: 'full',
    defaultOpen: true,
    fields: [
      { name: 'title', label: 'Title', type: 'text', required: true, span: 2, maxLength: 80 },
      {
        name: 'requester',
        label: 'Requested by',
        type: 'select',
        required: true,
        options: [
          {
            value: 'emp-1',
            label: 'Hana Solomon',
            description: 'Finance',
            avatar: 'HS',
            color: '#7c3aed',
          },
          {
            value: 'emp-2',
            label: 'Dawit Mekonnen',
            description: 'Site supervisor',
            avatar: 'DM',
            color: '#0891b2',
          },
          {
            value: 'emp-3',
            label: 'Selam Girma',
            description: 'Architect',
            avatar: 'SG',
            color: '#ea580c',
          },
        ],
      },
      { name: 'neededBy', label: 'Needed by', type: 'date', required: true, min: 'today' },
      {
        name: 'window',
        label: 'Delivery from',
        type: 'time',
        step: 30,
        min: '07:00',
        max: '17:00',
      },
      { name: 'budgetMonth', label: 'Budget month', type: 'month' },
    ],
  },
  {
    id: 'items',
    title: 'Items',
    icon: <Package />,
    columns: 3,
    span: 'full',
    fields: [
      {
        name: 'lines',
        label: 'Line items',
        type: 'rows',
        required: true,
        minLength: 1,
        addLabel: 'Add item',
        totals: ['qty', 'total'],
        columns: [
          { name: 'item', label: 'Item', type: 'text', required: true },
          { name: 'qty', label: 'Qty', type: 'number', required: true, min: 1 },
          { name: 'unit', label: 'Unit', type: 'select', options: ['pcs', 'm', 'm²', 'kg', 'bag'] },
          {
            name: 'cost',
            label: 'Unit cost',
            type: 'currency',
            required: true,
            min: 0,
            decimals: 2,
          },
          {
            name: 'status',
            label: 'Stock',
            type: 'select',
            options: ['In stock', 'Backorder'],
          },
          {
            name: 'eta',
            label: 'Expected',
            type: 'date',
            min: 'today',
            visibleWhen: { field: 'status', is: 'Backorder' },
            required: true,
          },
          {
            name: 'total',
            label: 'Line total',
            type: 'currency',
            decimals: 2,
            compute: (row) => money((Number(row.qty) || 0) * (Number(row.cost) || 0)).toFixed(2),
          },
        ],
        details: [
          {
            name: 'supplier',
            label: 'Supplier',
            type: 'lookup',
            search: searchCustomers,
            options: [customers[41]],
          },
          { name: 'notes', label: 'Notes', type: 'textarea' },
          {
            name: 'deliveries',
            label: 'Deliveries',
            type: 'rows',
            addLabel: 'Add delivery',
            totals: ['qty'],
            columns: [
              { name: 'date', label: 'Date', type: 'date', required: true },
              { name: 'qty', label: 'Qty', type: 'number', required: true, min: 1 },
              { name: 'received', label: 'Received', type: 'toggle' },
            ],
          },
        ],
      },
      {
        name: 'subtotal',
        label: 'Subtotal',
        type: 'currency',
        compute: (v) => money(lineTotal(v.lines)).toFixed(2),
      },
      {
        name: 'vat',
        label: 'VAT (15%)',
        type: 'currency',
        compute: (v) => money(lineTotal(v.lines) * 0.15).toFixed(2),
      },
      {
        name: 'total',
        label: 'Total',
        type: 'currency',
        compute: (v) => money(lineTotal(v.lines) * 1.15).toFixed(2),
      },
    ],
  },
  {
    id: 'phases',
    title: 'Phases',
    description:
      'Break the work into phases. Each phase has its own owner, budget and deliverables.',
    icon: <Layers />,
    span: 'full',
    fields: [
      {
        name: 'phases',
        label: 'Phases',
        type: 'group',
        itemLabel: 'Phase',
        titleField: 'name',
        minLength: 1,
        fields: [
          { name: 'name', label: 'Phase name', type: 'text', required: true },
          { name: 'owner', label: 'Owner', type: 'lookup', search: searchPeople, options: people },
          { name: 'period', label: 'Dates', type: 'daterange' },
          { name: 'budget', label: 'Budget', type: 'currency', decimals: 2 },
          {
            name: 'deliverables',
            label: 'Deliverables',
            type: 'rows',
            addLabel: 'Add deliverable',
            columns: [
              { name: 'title', label: 'Deliverable', type: 'text', required: true },
              { name: 'due', label: 'Due', type: 'date' },
              { name: 'done', label: 'Done', type: 'toggle' },
            ],
          },
        ],
      },
    ],
  },
  {
    id: 'payment',
    title: 'Payment schedule',
    description: 'Split the payment into milestones. The shares must add up to 100%.',
    icon: <Wallet />,
    fields: [
      {
        name: 'schedule',
        label: 'Milestones',
        type: 'rows',
        addLabel: 'Add milestone',
        totals: ['pct'],
        sum: { column: 'pct', equals: 100 },
        columns: [
          { name: 'name', label: 'Milestone', type: 'text', required: true },
          { name: 'pct', label: 'Share (%)', type: 'number', required: true, min: 1, max: 100 },
          { name: 'due', label: 'Due', type: 'date' },
        ],
      },
    ],
  },
  {
    id: 'approval',
    permissions: { edit: ['admin'] },
    title: 'Approval & files',
    icon: <Paperclip />,
    columns: 2,
    fields: [
      {
        name: 'approval',
        label: 'Decision',
        type: 'radio',
        span: 'full',
        options: [
          { value: 'approved', label: 'Approved', description: 'Purchasing can order everything.' },
          {
            value: 'partial',
            label: 'Partially approved',
            description: 'Only part is approved. Say which part below.',
          },
          { value: 'rejected', label: 'Not approved', description: 'Nothing is ordered.' },
        ],
      },
      {
        name: 'approvalNote',
        label: 'What is approved',
        type: 'textarea',
        span: 'full',
        visibleWhen: { field: 'approval', is: 'partial' },
        required: true,
      },
      {
        name: 'checks',
        label: 'Checks done',
        type: 'multiselect',
        display: 'checkboxes',
        options: [
          'Three quotes compared',
          'Within budget',
          'Supplier licence valid',
          'Specs signed off',
        ],
      },
      { name: 'specs', label: 'Specification notes', type: 'list', maxLength: 6 },
      {
        name: 'quotes',
        label: 'Quotes',
        type: 'file',
        accept: '.pdf,image/*',
        maxSize: 8 * 1024 * 1024,
        maxLength: 3,
      },
    ],
  },
]

const fakeUpload: Uploader = (file, { onProgress, signal }) =>
  new Promise((resolve, reject) => {
    let progress = 0
    const timer = setInterval(() => {
      progress += 8 + Math.random() * 14
      onProgress(Math.min(progress, 100))
      if (progress < 100) return
      clearInterval(timer)
      if (/fail/i.test(file.name)) reject(new Error('Server rejected the file'))
      else resolve({ url: URL.createObjectURL(file) })
    }, 180)
    signal.addEventListener('abort', () => {
      clearInterval(timer)
      reject(new DOMException('Upload cancelled', 'AbortError'))
    })
  })

const fr: Messages = {
  required: '{label} est obligatoire',
  email: 'Saisissez une adresse e-mail valide',
  minNumber: 'Doit être au moins {min}',
  maxNumber: 'Doit être au plus {max}',
  minDate: 'Doit être le {min} ou après',
  minCount: 'Ajoutez au moins {count} {noun}',
  sumEquals: '{label} doit totaliser {equals} (actuellement {total})',
  'noun.row_one': 'ligne',
  'noun.row_other': 'lignes',
  yes: 'Oui',
  no: 'Non',
  searchFields: 'Rechercher des champs',
  needsAttention: 'À vérifier',
  calculated: 'Calculé automatiquement',
  next: 'Suivant',
  back: 'Retour',
  review: 'Vérifier',
  submit: 'Envoyer',
  addRow: 'Ajouter une ligne',
  pasteTip: 'Astuce : collez depuis Excel ou Sheets · Entrée sur la dernière ligne en ajoute une',
  chooseFiles: 'Choisir des fichiers',
  orDropMany: 'ou déposez-les ici',
  uploadingPercent: 'Envoi… {progress} %',
  uploadError: 'Échec de l’envoi',
  retry: 'Réessayer',
  today: 'Aujourd’hui',
  now: 'Maintenant',
  clear: 'Effacer',
  pickDate: 'Choisir une date',
  pickDates: 'Choisir des dates',
  draftFound: 'Vous avez un brouillon non enregistré de {time}.',
  restore: 'Restaurer',
  discard: 'Ignorer',
  autosaving: 'Enregistrement…',
  autosavedAt: 'Enregistré à {time}',
  undo: 'Annuler',
  rowDeleted: 'Ligne {n} supprimée',
}

const schema = z.object({
  title: z.string().refine((t) => !/^untitled/i.test(t), 'Give the request a real title'),
})

const layouts: FormLayout[] = ['accordion', 'tabs', 'cards', 'steps']

function Pills<T extends string>({
  label,
  value,
  options,
  onChange,
}: {
  label: string
  value: T
  options: readonly T[]
  onChange: (value: T) => void
}) {
  return (
    <div role="radiogroup" aria-label={label} className="flex rounded-lg bg-muted p-0.5">
      {options.map((o) => (
        <button
          key={o}
          type="button"
          role="radio"
          aria-checked={value === o}
          onClick={() => onChange(o)}
          className={cn(
            'h-7 rounded-md px-3 text-sm capitalize transition-colors',
            value === o
              ? 'bg-card font-medium text-foreground shadow-(--shadow-button)'
              : 'text-muted-foreground hover:text-foreground',
          )}
        >
          {o}
        </button>
      ))}
    </div>
  )
}

const defaultValues = applyComputed(sections, {
  customer: 'cus-42',
  team: ['emp-3'],
  phone: '+251 911 234 567',
  site: '',
  period: '',
  inspection: '',
  value: '1500000',
  retention: '5',
  effort: '2160',
  scope: '<p>Supply and fix <b>rebar</b> for the ground slab.</p>',
  signedBy: '',
  title: 'Untitled request',
  requester: 'emp-2',
  neededBy: '',
  window: '08:00',
  budgetMonth: '2026-11',
  lines: [
    {
      id: 'a',
      item: 'Rebar 12 mm',
      qty: '40',
      unit: 'pcs',
      cost: '620.00',
      status: 'In stock',
      eta: '',
      supplier: 'cus-42',
      notes: '',
      deliveries: [{ id: 'd1', date: '2026-11-20', qty: '20', received: false }],
    },
    {
      id: 'b',
      item: 'Cement',
      qty: '25',
      unit: 'bag',
      cost: '',
      status: 'Backorder',
      eta: '',
      supplier: '',
      notes: '',
      deliveries: [],
    },
  ],
  phases: [
    {
      id: 'ph1',
      name: 'Foundations',
      owner: 'emp-2',
      period: '',
      budget: '450000.00',
      deliverables: [{ id: 'dl1', title: 'Excavation sign-off', due: '', done: false }],
    },
  ],
  schedule: [
    { id: 'p1', name: 'Advance', pct: '30', due: '' },
    { id: 'p2', name: 'On delivery', pct: '60', due: '' },
  ],
  approval: 'approved',
  approvalNote: '',
  checks: ['Within budget'],
  specs: ['Grade 60 steel'],
  quotes: [],
})

export default function Demo() {
  const [layout, setLayout] = useState<FormLayout>('accordion')
  const [mode, setMode] = useState<'edit' | 'view'>('edit')
  const [saved, setSaved] = useState(false)
  const [role, setRole] = useState<'viewer' | 'finance' | 'admin'>('admin')
  const [lang, setLang] = useState<'en' | 'fr'>('en')
  const [autosaveOn, setAutosaveOn] = useState<'off' | 'on'>('off')
  const roles = useMemo(() => [role], [role])
  const autosave = useMemo(
    () =>
      autosaveOn === 'on'
        ? { save: () => new Promise<void>((r) => setTimeout(r, 700)), delay: 1200 }
        : undefined,
    [autosaveOn],
  )
  const form = useForm<FormValues>({
    defaultValues,
    mode: 'onBlur',
    resolver: recordFormResolver({
      sections,
      schema,
      roles,
      locale: lang,
      messages: lang === 'fr' ? fr : undefined,
    }),
  })
  const changes = useFormChanges(form, sections, { roles, locale: lang })

  const save = async (values: FormValues) => {
    await toast.promise(new Promise((r) => setTimeout(r, 800)), {
      loading: 'Saving request…',
      success: 'Request saved',
      error: 'Couldn’t save the request',
    })
    form.reset(values)
    setSaved(true)
    setTimeout(() => setSaved(false), 1500)
  }

  return (
    <div className="flex w-full max-w-4xl flex-col gap-4">
      <div className="flex flex-wrap items-center gap-2">
        <Pills label="Layout" value={layout} options={layouts} onChange={setLayout} />
        <Pills label="Mode" value={mode} options={['edit', 'view'] as const} onChange={setMode} />
        <Pills
          label="Role"
          value={role}
          options={['viewer', 'finance', 'admin'] as const}
          onChange={setRole}
        />
        <Pills label="Language" value={lang} options={['en', 'fr'] as const} onChange={setLang} />
        <Pills
          label="Autosave"
          value={autosaveOn}
          options={['off', 'on'] as const}
          onChange={setAutosaveOn}
        />
      </div>
      <RecordForm
        key={layout + mode}
        form={form}
        sections={sections}
        onSubmit={save}
        layout={layout}
        mode={mode}
        sectionColumns={layout === 'cards' ? 2 : 1}
        currency="ETB "
        submitLabel="Send for approval"
        roles={roles}
        locale={lang}
        messages={lang === 'fr' ? fr : undefined}
        upload={fakeUpload}
        draftKey="demo-purchase-request"
        autosave={autosave}
      />
      <ChangeList changes={changes} />
      <Toaster />
      {layout !== 'steps' && (
        <div className="flex justify-end gap-2">
          <Button disabled={!form.formState.isDirty} onClick={() => form.reset()}>
            Discard
          </Button>
          <Button
            variant="primary"
            loading={form.formState.isSubmitting}
            onClick={form.handleSubmit(save)}
          >
            {saved ? 'Saved ✓' : 'Save request'}
          </Button>
        </div>
      )}
    </div>
  )
}

Docs

Built on react-hook-form

Record Form doesn't keep its own state. You own the form with useForm, and RecordForm renders every field as a react-hook-form Controller. Everything you know from react-hook-form applies: mode, reset, watch, formState, handleSubmit.

tsx
const form = useForm<FormValues>({
  defaultValues,
  mode: 'onBlur',
  resolver: recordFormResolver({ sections }),
})

return (
  <form onSubmit={form.handleSubmit(save)}>
    <RecordForm form={form} sections={sections} />
    <button type="submit">Save</button>
  </form>
)

Validation

recordFormResolver runs the rules in your sections (required, min, pattern, conditions, row rules...) and returns them as react-hook-form errors. Pass any Standard Schema (Zod, Valibot, ArkType) as schema for cross-field checks. Both run, and errors merge by field path.

tsx
const schema = z.object({ title: z.string().min(3) })

useForm({ resolver: recordFormResolver({ sections, schema, roles, locale: 'fr', messages: fr }) })

Saving

  • Your own button: form.handleSubmit(save).
  • Steps layout: pass onSubmit={save} and the last step submits through handleSubmit.
  • After a save, call form.reset(values) so the form is clean again.

Calculated fields are written back into the values while the user types, and the resolver returns the final computed values to your submit handler.

Changes, drafts and autosave

tsx
const changes = useFormChanges(form, sections)

<RecordForm
  form={form}
  sections={sections}
  draftKey="order-1078"
  autosave={{ save: (values) => api.save(values), delay: 1500 }}
/>
<ChangeList changes={changes} />

draftKey keeps unsaved edits in localStorage and offers to restore them. autosave saves after a pause and resets the form's defaults to what was saved. The leave warning turns on while the form is dirty or uploading. Turn it off with warnOnLeave={false}.

Props

PropTypeDefault
formUseFormReturn<FormValues>required
sectionsFormSection[]required
layout'accordion' | 'tabs' | 'cards' | 'steps''accordion'
mode'edit' | 'view''edit'
onSubmitSubmitHandler<FormValues>none
draftKeystringnone
autosave{ save, delay? }none
upload(file, { onProgress, signal }) => Promisenone
rolesstring[]none
localestring'en'
fieldTypesRecord<string, FieldTypeRenderer>none

Source code

record-form.tsx
'use client'

import { useRef, useState } from 'react'
import {
  Controller,
  useFormState,
  useWatch,
  type SubmitHandler,
  type UseFormReturn,
} from 'react-hook-form'

import { cn } from '@/lib/utils'
import { Field } from '@/registry/dashboard/components/input/input'

import {
  ConfigProvider,
  useFormConfig,
  useResolvedConfig,
  type RecordFormSettings,
} from './context'
import {
  DraftBanner,
  SaveStatus,
  useAutosave,
  useDraft,
  useLeaveWarning,
  type AutosaveOptions,
} from './drafts'
import { FieldControl, type FieldTypeRenderer } from './field-control'
import { flattenErrors } from './resolver'
import {
  applyComputed,
  formatValue,
  isEmptyValue,
  isReadOnly,
  isRequired,
  isVisible,
  canEdit,
  type FieldDef,
  type FieldValue,
  type FormSection,
  type FormValues,
} from './schema'

export {
  applyComputed,
  diffValues,
  evaluate,
  formatValue,
  isEmptyValue,
  isReadOnly,
  isRequired,
  isVisible,
  normalizeOptions,
  optionLabel,
  validateField,
  validateFields,
  validateRecord,
  visibleFields,
} from './schema'
export type {
  BuiltInFieldType,
  Change,
  Condition,
  CustomFieldType,
  FieldDef,
  FieldType,
  FieldTypeMap,
  FieldValue,
  FileItem,
  FormErrors,
  FormSection,
  FormValues,
  Option,
  OptionObject,
  Row,
  Rule,
} from './schema'
export { FieldControl } from './field-control'
export { ChangeList, useFormChanges } from './changes'
export { FORM_ERROR, flattenErrors, recordFormResolver } from './resolver'
export type { RecordFormResolverOptions, StandardSchema } from './resolver'
export { RecordFormProvider, useFormConfig } from './context'
export type { RecordFormSettings } from './context'
export { createT, en } from './i18n'
export type { Messages, Translate } from './i18n'
export { xhrUploader } from './upload'
export type { Uploader, UploadContext, UploadResult } from './upload'
export { DraftBanner, SaveStatus, useAutosave, useDraft, useLeaveWarning } from './drafts'
export type { AutosaveOptions, AutosaveState, DraftState } from './drafts'
export { canEdit, canView } from './schema'
export type { Permissions } from './schema'
export type { FieldRenderProps, FieldTypeRenderer } from './field-control'

export type FormLayout = 'accordion' | 'tabs' | 'cards' | 'steps'

type RecordFormProps = {
  form: UseFormReturn<FormValues>
  sections: FormSection[]
  onSubmit?: SubmitHandler<FormValues>
  layout?: FormLayout
  mode?: 'edit' | 'view'
  readOnly?: boolean
  sectionColumns?: 1 | 2
  fieldTypes?: Record<string, FieldTypeRenderer>
  searchable?: boolean
  currency?: string
  review?: boolean
  submitLabel?: string
  draftKey?: string
  autosave?: AutosaveOptions
  warnOnLeave?: boolean
  className?: string
} & RecordFormSettings

const NO_TYPES: Record<string, FieldTypeRenderer> = {}

const gridCols = {
  1: '',
  2: '@sm:grid-cols-2',
  3: '@sm:grid-cols-2 @xl:grid-cols-3',
  4: '@sm:grid-cols-2 @3xl:grid-cols-4',
}

const wide = new Set(['rows', 'group', 'file', 'richtext', 'signature'])

function spanClass(field: FieldDef, columns: number) {
  const span = wide.has(field.type) ? (field.span ?? 'full') : field.span
  if (!span || span === 1 || columns === 1) return undefined
  if (span === 'full' || span >= columns) return 'col-span-full'
  if (span === 2) return '@sm:col-span-2'
  return '@sm:col-span-full @3xl:col-span-3'
}

const button =
  'inline-flex h-8 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-medium transition-colors disabled:opacity-50'

const touches = (key: string, name: string) => key === name || key.startsWith(`${name}.`)

export function RecordForm({
  form,
  sections,
  onSubmit,
  layout = 'accordion',
  mode = 'edit',
  readOnly = false,
  sectionColumns = 1,
  fieldTypes = NO_TYPES,
  searchable = layout !== 'steps',
  currency = '$',
  review = true,
  submitLabel,
  draftKey,
  autosave: autosaveOptions,
  warnOnLeave = true,
  className,
  locale: localeProp,
  messages,
  translate,
  roles: rolesProp,
  upload,
}: RecordFormProps) {
  const parent = useFormConfig()
  const config = useResolvedConfig(
    { locale: localeProp, messages, translate, roles: rolesProp, upload },
    parent,
  )
  const { t, roles, locale } = config
  const [query, setQuery] = useState('')
  const [open, setOpen] = useState(
    () => new Set(sections.filter((s) => s.defaultOpen).map((s) => s.id)),
  )
  const [tab, setTab] = useState(sections[0]?.id)
  const [step, setStep] = useState(0)
  const [reached, setReached] = useState(0)
  const [checking, setChecking] = useState(false)
  const [editing, setEditing] = useState<{ name: string; before: FieldValue | undefined } | null>(
    null,
  )
  const values = useWatch({ control: form.control }) as FormValues
  const formState = useFormState({ control: form.control })
  const errors = flattenErrors(formState.errors)
  const errorKey = Object.keys(errors).join('|')
  const [seenErrors, setSeenErrors] = useState(errorKey)
  const focusedFor = useRef<string | null>(null)
  const draft = useDraft(form, draftKey)
  const autosave = useAutosave(form, autosaveOptions)
  const uploading = Object.values(values).some(
    (v) =>
      Array.isArray(v) &&
      v.some(
        (item) =>
          item && typeof item === 'object' && 'status' in item && item.status === 'uploading',
      ),
  )
  useLeaveWarning(warnOnLeave && (formState.isDirty || uploading))
  const submit = () => (onSubmit ? form.handleSubmit(onSubmit)() : Promise.resolve())
  const q = query.trim().toLowerCase()

  const shownSections = sections
    .filter((s) => isVisible(s, values, roles))
    .map((s) => ({
      ...s,
      fields: s.fields
        .filter((f) => isVisible(f, values, roles))
        .map((f) => (canEdit(s, roles) ? f : { ...f, readOnly: true })),
    }))
    .filter((s) => s.fields.length)

  if (seenErrors !== errorKey) {
    setSeenErrors(errorKey)
    const failing = shownSections.filter((s) =>
      s.fields.some((f) => Object.keys(errors).some((k) => touches(k, f.name))),
    )
    if (failing.length) {
      if (layout === 'tabs' && !failing.some((s) => s.id === tab)) setTab(failing[0].id)
      if (layout === 'accordion') setOpen(new Set([...open, ...failing.map((s) => s.id)]))
      if (layout === 'steps') {
        const first = shownSections.indexOf(failing[0])
        if (first < step) setStep(first)
      }
    }
  }

  const searched = shownSections
    .map((s) => ({ ...s, fields: s.fields.filter((f) => f.label.toLowerCase().includes(q)) }))
    .filter((s) => s.fields.length)

  const valueOf = (field: FieldDef) => (field.compute ? field.compute(values) : values[field.name])

  const errorIn = (field: FieldDef) =>
    Object.keys(errors).some((k) => errors[k] && touches(k, field.name))

  const stats = (section: FormSection) => {
    const all = shownSections.find((s) => s.id === section.id)?.fields ?? []
    const empty = (f: FieldDef) =>
      fieldTypes[f.type]?.isEmpty?.(valueOf(f)) ?? isEmptyValue(valueOf(f))
    return {
      filled: all.filter((f) => !empty(f)).length,
      total: all.length,
      hasError: all.some(errorIn),
    }
  }

  const syncComputed = () => {
    const current = form.getValues()
    const next = applyComputed(sections, current)
    for (const [name, value] of Object.entries(next))
      if (next[name] !== current[name]) form.setValue(name, value, { shouldDirty: true })
  }

  const hint = (field: FieldDef) => {
    const base = field.hint ?? (field.compute ? t('calculated') : undefined)
    const counted =
      field.maxLength !== undefined && (field.type === 'text' || field.type === 'textarea')
    if (!counted) return base
    const length = String(values[field.name] ?? '').length
    return (
      <span className="flex justify-between gap-3">
        <span>{base}</span>
        <span className={cn('tabular-nums', length > field.maxLength! && 'text-destructive')}>
          {length}/{field.maxLength}
        </span>
      </span>
    )
  }

  const control = (field: FieldDef) => (
    <Controller
      name={field.name}
      control={form.control}
      render={({ field: rhf, fieldState }) => {
        const id = `field-${field.name}`
        const invalid = fieldState.invalid
        return (
          <div
            className="contents"
            onBlur={(e) => {
              if (!e.currentTarget.contains(e.relatedTarget as Node | null)) rhf.onBlur()
            }}
          >
            <FieldControl
              id={id}
              field={field}
              value={field.compute ? valueOf(field) : rhf.value}
              values={values}
              invalid={invalid}
              disabled={readOnly || isReadOnly(field, values, roles)}
              describedBy={invalid ? `${id}-error` : field.hint ? `${id}-hint` : undefined}
              currency={currency}
              errors={errors}
              fieldTypes={fieldTypes}
              onChange={(v) => {
                rhf.onChange(v)
                syncComputed()
              }}
            />
          </div>
        )
      }}
    />
  )

  const viewField = (field: FieldDef, columns: number) => {
    const locked = readOnly || isReadOnly(field, values, roles)
    const isEditing = editing?.name === field.name
    const rich = wide.has(field.type)
    const value = valueOf(field)
    const formatted = formatValue(field, value, fieldTypes, { t, locale })
    const text =
      field.type === 'currency' && formatted !== '—' ? `${currency}${formatted}` : formatted
    const error = errors[field.name]

    const finish = (cancel: boolean) => {
      if (cancel && editing) {
        form.setValue(field.name, editing.before, { shouldDirty: true })
        syncComputed()
      }
      focusedFor.current = null
      setEditing(null)
    }

    return (
      <div
        key={field.name}
        className={cn('flex min-w-0 flex-col gap-1', spanClass(field, columns))}
      >
        <span id={`field-${field.name}-label`} className="text-xs text-muted-foreground">
          {field.label}
          {isEditing && isRequired(field, values) && <span> *</span>}
        </span>
        {isEditing ? (
          <div
            className="flex flex-col gap-2"
            ref={(el) => {
              if (!el || focusedFor.current === field.name) return
              focusedFor.current = field.name
              const target = el.querySelector<HTMLElement>(
                'input:not([type=file]), textarea, button',
              )
              target?.focus()
              if (target instanceof HTMLInputElement) target.select()
            }}
            onKeyDown={(e) => {
              if (e.key === 'Escape') {
                e.stopPropagation()
                finish(true)
              }
              if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') finish(false)
            }}
          >
            {control(field)}
            {error && <span className="text-xs text-destructive">{error}</span>}
            <div className="flex gap-1.5">
              <button
                type="button"
                onClick={() => finish(false)}
                className={cn(button, 'h-7 bg-primary px-2.5 text-xs text-primary-foreground')}
              >
                {t('done')}
              </button>
              <button
                type="button"
                onClick={() => finish(true)}
                className={cn(button, 'h-7 px-2.5 text-xs text-muted-foreground hover:bg-muted')}
              >
                {t('cancel')}
              </button>
            </div>
          </div>
        ) : rich ? (
          <div className="flex flex-col items-start gap-1.5">
            {isEmptyValue(value) ? (
              <span className="text-sm text-muted-foreground">—</span>
            ) : (
              <div className="w-full">
                <FieldControl
                  id={`field-${field.name}`}
                  field={field}
                  value={value}
                  values={values}
                  invalid={false}
                  disabled
                  currency={currency}
                  fieldTypes={fieldTypes}
                  onChange={() => {}}
                />
              </div>
            )}
            {!locked && (
              <button
                type="button"
                onClick={() => setEditing({ name: field.name, before: values[field.name] })}
                className="text-xs font-medium text-muted-foreground hover:text-foreground"
              >
                {t('editField', { label: field.label.toLowerCase() })}
              </button>
            )}
          </div>
        ) : (
          <button
            type="button"
            disabled={locked}
            aria-labelledby={`field-${field.name}-label`}
            title={locked ? undefined : t('clickToEdit')}
            onClick={() => setEditing({ name: field.name, before: values[field.name] })}
            className={cn(
              'group -mx-1.5 flex min-h-7 items-center justify-between gap-2 rounded-md px-1.5 text-left text-sm transition-colors',
              !locked && 'hover:bg-muted',
              error && 'ring-1 ring-destructive',
              text === '—' && 'text-muted-foreground',
            )}
          >
            <span className="min-w-0 break-words whitespace-pre-wrap">{text}</span>
            {!locked && (
              <span
                aria-hidden
                className="text-xs text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
              >
                ✎
              </span>
            )}
          </button>
        )}
        {!isEditing && error && <span className="text-xs text-destructive">{error}</span>}
      </div>
    )
  }

  const fields = (section: FormSection) => {
    const columns = section.columns ?? 1
    return (
      <div className="@container rounded-xl bg-card p-3 shadow-(--shadow-card)">
        <div className={cn('grid gap-4 [&>*]:min-w-0', gridCols[columns])}>
          {section.fields.map((field) =>
            mode === 'view' ? (
              viewField(field, columns)
            ) : (
              <Field
                key={field.name}
                label={field.label}
                htmlFor={`field-${field.name}`}
                hint={hint(field)}
                error={errors[field.name]}
                required={!readOnly && isRequired(field, values)}
                className={spanClass(field, columns)}
              >
                {control(field)}
              </Field>
            ),
          )}
        </div>
      </div>
    )
  }

  const count = (section: FormSection) => {
    const s = stats(section)
    return (
      <span
        className={cn(
          'text-xs font-normal tabular-nums',
          s.hasError ? 'text-destructive' : 'text-muted-foreground',
        )}
      >
        {s.hasError ? t('needsAttention') : `${s.filled}/${s.total}`}
      </span>
    )
  }

  const heading = (section: FormSection, extra?: React.ReactNode) => (
    <div className="flex items-start gap-2.5 text-[15px] font-medium [&_svg]:mt-0.5 [&_svg]:size-4 [&_svg]:text-muted-foreground">
      {section.icon}
      <div className="flex min-w-0 flex-1 flex-col gap-0.5">
        <h3>{section.title}</h3>
        {section.description && (
          <p className="text-sm font-normal text-muted-foreground">{section.description}</p>
        )}
      </div>
      {extra ?? count(section)}
    </div>
  )

  const cards = (list: FormSection[]) => (
    <div className={cn('grid gap-6 [&>*]:min-w-0', sectionColumns === 2 && '@3xl:grid-cols-2')}>
      {list.map((section) => (
        <section
          key={section.id}
          className={cn(
            'flex flex-col gap-3',
            sectionColumns === 2 && section.span === 'full' && 'col-span-full',
          )}
        >
          {heading(section)}
          {fields(section)}
        </section>
      ))}
    </div>
  )

  const toggle = (id: string) => {
    const next = new Set(open)
    if (next.has(id)) next.delete(id)
    else next.add(id)
    setOpen(next)
  }

  const accordion = (list: FormSection[]) =>
    list.map((section) => {
      const expanded = q ? true : open.has(section.id)
      return (
        <section key={section.id} className="border-b py-1 last:border-0">
          <button
            type="button"
            aria-expanded={expanded}
            onClick={() => toggle(section.id)}
            className="flex h-11 w-full items-center gap-2.5 text-left text-[15px] font-medium text-muted-foreground hover:text-foreground aria-expanded:text-foreground [&_svg]:size-4"
          >
            {section.icon}
            <span className="flex-1">{section.title}</span>
            {count(section)}
            <span
              aria-hidden
              className={cn('text-[10px] transition-transform', expanded && 'rotate-180')}
            >
              ▾
            </span>
          </button>
          {expanded && (
            <div className="mb-3 flex flex-col gap-3">
              {section.description && (
                <p className="text-sm text-muted-foreground">{section.description}</p>
              )}
              {fields(section)}
            </div>
          )}
        </section>
      )
    })

  const tabs = () => {
    const current = shownSections.find((s) => s.id === tab) ?? shownSections[0]
    if (!current) return null
    return (
      <div className="flex flex-col gap-4">
        <div
          role="tablist"
          aria-label="Sections"
          className="flex gap-1 overflow-x-auto rounded-xl bg-muted p-1"
        >
          {shownSections.map((section) => (
            <button
              key={section.id}
              type="button"
              role="tab"
              id={`tab-${section.id}`}
              aria-selected={section.id === current.id}
              aria-controls={`panel-${section.id}`}
              onClick={() => setTab(section.id)}
              className={cn(
                'flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-3 text-sm font-medium whitespace-nowrap transition-colors [&_svg]:size-3.5',
                section.id === current.id
                  ? 'bg-card text-foreground shadow-(--shadow-button)'
                  : 'text-muted-foreground hover:text-foreground',
              )}
            >
              {section.icon}
              {section.title}
              {stats(section).hasError && (
                <span
                  aria-label="Needs attention"
                  className="size-1.5 rounded-full bg-destructive"
                />
              )}
            </button>
          ))}
        </div>
        <div role="tabpanel" id={`panel-${current.id}`} aria-labelledby={`tab-${current.id}`}>
          {current.description && (
            <p className="mb-3 text-sm text-muted-foreground">{current.description}</p>
          )}
          {fields(current)}
        </div>
      </div>
    )
  }

  const steps = () => {
    const last = shownSections.length - 1
    const reviewing = review && step > last
    const current = shownSections[Math.min(step, last)]
    if (!current) return null
    const labels = [...shownSections.map((s) => s.title), ...(review ? [t('review')] : [])]

    const go = (to: number) => {
      setStep(to)
      setReached(Math.max(reached, to))
    }

    const check = (section: FormSection) => form.trigger(section.fields.map((f) => f.name))

    const next = async () => {
      setChecking(true)
      const ok = await check(current).finally(() => setChecking(false))
      if (!ok) return
      if (step < last || review) go(step + 1)
      else await submit()
    }

    const submitAll = async () => {
      setChecking(true)
      try {
        for (let i = 0; i < shownSections.length; i++) {
          if (!(await check(shownSections[i]))) return setStep(i)
        }
      } finally {
        setChecking(false)
      }
      await submit()
    }

    return (
      <div className="flex flex-col gap-5">
        <ol className="flex items-center gap-2 overflow-x-auto pb-1">
          {labels.map((label, i) => {
            const active = i === step
            const done = i < step
            const hasError = i <= last && stats(shownSections[i]).hasError
            return (
              <li key={label} className="flex shrink-0 items-center gap-2">
                {i > 0 && <span aria-hidden className="h-px w-6 bg-border" />}
                <button
                  type="button"
                  disabled={i > reached}
                  aria-current={active ? 'step' : undefined}
                  onClick={() => setStep(i)}
                  className="flex items-center gap-2 text-sm disabled:cursor-default"
                >
                  <span
                    className={cn(
                      'flex size-6 items-center justify-center rounded-full border text-xs font-medium tabular-nums',
                      hasError
                        ? 'border-destructive text-destructive'
                        : active
                          ? 'border-primary bg-primary text-primary-foreground'
                          : done
                            ? 'border-foreground/20 bg-muted text-foreground'
                            : 'text-muted-foreground',
                    )}
                  >
                    {done && !hasError ? '✓' : i + 1}
                  </span>
                  <span
                    className={cn(
                      'whitespace-nowrap',
                      active ? 'font-medium text-foreground' : 'text-muted-foreground',
                    )}
                  >
                    {label}
                  </span>
                </button>
              </li>
            )
          })}
        </ol>

        {reviewing ? (
          <div className="flex flex-col gap-4">
            {shownSections.map((section, i) => (
              <section key={section.id} className="rounded-xl bg-card p-4 shadow-(--shadow-card)">
                {heading(
                  section,
                  <button
                    type="button"
                    onClick={() => setStep(i)}
                    className="text-sm font-medium text-muted-foreground hover:text-foreground"
                  >
                    {t('edit')}
                  </button>,
                )}
                <dl className={cn('mt-3 grid gap-x-6 gap-y-3', gridCols[section.columns ?? 2])}>
                  {section.fields.map((f) => (
                    <div
                      key={f.name}
                      className={cn(
                        'flex min-w-0 flex-col gap-0.5',
                        spanClass(f, section.columns ?? 2),
                      )}
                    >
                      <dt className="text-xs text-muted-foreground">{f.label}</dt>
                      <dd className="text-sm break-words">
                        {formatValue(f, valueOf(f), fieldTypes, { t, locale })}
                      </dd>
                    </div>
                  ))}
                </dl>
              </section>
            ))}
          </div>
        ) : (
          <section className="flex flex-col gap-3">
            {heading(current, <span />)}
            {fields(current)}
          </section>
        )}

        <div className="flex items-center justify-between gap-2 border-t pt-4">
          <button
            type="button"
            disabled={step === 0}
            onClick={() => setStep(step - 1)}
            className={cn(button, 'text-muted-foreground hover:bg-muted disabled:invisible')}
          >
            {t('back')}
          </button>
          <span className="text-xs text-muted-foreground tabular-nums">
            {t('stepOf', { step: step + 1, total: labels.length })}
          </span>
          <button
            type="button"
            disabled={formState.isSubmitting || checking}
            aria-busy={checking || undefined}
            onClick={() => void (reviewing ? submitAll() : next())}
            className={cn(button, 'bg-primary text-primary-foreground hover:bg-primary/90')}
          >
            {reviewing || (!review && step === last)
              ? formState.isSubmitting
                ? t('saving')
                : (submitLabel ?? t('submit'))
              : checking
                ? t('checking')
                : step === last
                  ? t('review')
                  : t('next')}
          </button>
        </div>
      </div>
    )
  }

  return (
    <ConfigProvider value={config}>
      <div className={cn('@container flex flex-col', className)}>
        {draft && <DraftBanner draft={draft} />}
        {autosave && autosave.status !== 'idle' && (
          <div className="mb-2 flex justify-end">
            <SaveStatus state={autosave} />
          </div>
        )}
        {searchable && (
          <div className="mb-3 flex h-8 items-center gap-2 rounded-lg border border-input bg-card px-2.5 focus-within:border-ring">
            <input
              value={query}
              onChange={(e) => setQuery(e.target.value)}
              onKeyDown={(e) => e.key === 'Escape' && setQuery('')}
              placeholder={t('searchFields')}
              aria-label={t('searchFields')}
              className="h-full min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
            />
            {query && (
              <button
                type="button"
                aria-label={t('clearSearch')}
                onClick={() => setQuery('')}
                className="text-xs text-muted-foreground hover:text-foreground"
              >
                ✕
              </button>
            )}
          </div>
        )}
        {q
          ? layout === 'accordion'
            ? accordion(searched)
            : cards(searched)
          : layout === 'steps'
            ? steps()
            : layout === 'tabs'
              ? tabs()
              : layout === 'cards'
                ? cards(shownSections)
                : accordion(shownSections)}
        {q && !searched.length && (
          <p className="py-6 text-center text-sm text-muted-foreground">
            {t('noFieldsMatch', { query })}
          </p>
        )}
      </div>
    </ConfigProvider>
  )
}
changes.tsx
import { useFormState, useWatch, type UseFormReturn } from 'react-hook-form'

import { cn } from '@/lib/utils'

import { useFormConfig, useResolvedConfig, type RecordFormSettings } from './context'
import {
  diffValues,
  type Change,
  type FieldTypeMap,
  type FormSection,
  type FormValues,
} from './schema'

export function useFormChanges(
  form: UseFormReturn<FormValues>,
  sections: FormSection[],
  { fieldTypes, ...settings }: { fieldTypes?: FieldTypeMap } & RecordFormSettings = {},
): Change[] {
  const { t, locale, roles } = useResolvedConfig(settings, useFormConfig())
  const values = useWatch({ control: form.control }) as FormValues
  const { defaultValues = {} } = useFormState({ control: form.control })
  return diffValues(sections, defaultValues as FormValues, values, fieldTypes, { t, locale, roles })
}

type ChangeListProps = {
  changes: Change[]
  title?: React.ReactNode
  className?: string
}

export function ChangeList({ changes, title = 'Unsaved changes', className }: ChangeListProps) {
  if (!changes.length) return null
  return (
    <section className={cn('flex flex-col gap-3', className)}>
      <h2 className="flex items-center gap-2 text-base font-semibold">
        {title}
        <span className="rounded-full bg-muted px-2 text-xs font-medium tabular-nums">
          {changes.length}
        </span>
      </h2>
      <ul className="divide-y rounded-xl bg-card px-4 shadow-(--shadow-card)">
        {changes.map((c) => (
          <li
            key={c.id}
            className="grid grid-cols-[minmax(0,10rem)_1fr] items-baseline gap-4 py-2.5 text-sm"
          >
            <span className="truncate text-muted-foreground">{c.label}</span>
            <span className="flex min-w-0 flex-wrap items-baseline gap-x-2">
              <span className="text-muted-foreground line-through">{c.from}</span>
              <span aria-hidden className="text-muted-foreground">
                →
              </span>
              <span className="font-medium">{c.to}</span>
            </span>
          </li>
        ))}
      </ul>
    </section>
  )
}
context.tsx
'use client'

import { createContext, useContext, useMemo } from 'react'

import { createT, defaultT, type Messages, type T, type Translate } from './i18n'
import type { Uploader } from './upload'

export type RecordFormSettings = {
  locale?: string
  messages?: Messages
  translate?: Translate
  roles?: string[]
  upload?: Uploader
}

export type RecordFormConfig = Omit<RecordFormSettings, 'messages' | 'translate'> & { t: T }

const Config = createContext<RecordFormConfig>({ t: defaultT })

export const useFormConfig = () => useContext(Config)

export function RecordFormProvider({
  children,
  ...settings
}: RecordFormSettings & { children: React.ReactNode }) {
  const parent = useContext(Config)
  const value = useResolvedConfig(settings, parent)
  return <Config value={value}>{children}</Config>
}

export function useResolvedConfig(settings: RecordFormSettings, parent?: RecordFormConfig) {
  const { locale, messages, translate, roles, upload } = settings
  return useMemo<RecordFormConfig>(() => {
    const base = parent ?? { t: defaultT }
    return {
      locale: locale ?? base.locale,
      roles: roles ?? base.roles,
      upload: upload ?? base.upload,
      t: messages || translate || locale ? createT({ messages, translate, locale }) : base.t,
    }
  }, [locale, messages, translate, roles, upload, parent])
}

export const ConfigProvider = Config
controls.tsx
'use client'

import { useRef, useState } from 'react'

import { cn } from '@/lib/utils'

import { useFormConfig } from './context'
import { Popover } from '@/registry/dashboard/components/popover/popover'
import { normalizeOptions, type Option, type OptionObject } from './schema'

export const triggerClass =
  'border-input bg-card text-foreground hover:border-ring flex min-h-8 w-full items-center justify-between gap-2 rounded-lg border px-2.5 py-1 text-left text-sm outline-none focus-visible:border-ring disabled:cursor-default disabled:bg-muted/40 disabled:hover:border-input aria-disabled:cursor-default aria-disabled:bg-muted/40 aria-disabled:hover:border-input'

const chevron = (
  <span aria-hidden className="text-[10px] text-muted-foreground">
    ▾
  </span>
)

export const optionRow =
  'flex min-h-8 w-full items-center gap-2 rounded-lg px-2 py-1 text-left text-sm hover:bg-muted'

function OptionSearch({
  value,
  onChange,
  onEnter,
}: {
  value: string
  onChange: (value: string) => void
  onEnter?: () => void
}) {
  const { t } = useFormConfig()
  return (
    <input
      autoFocus
      value={value}
      onChange={(e) => onChange(e.target.value)}
      onKeyDown={(e) => {
        if (e.key === 'Enter') {
          e.preventDefault()
          onEnter?.()
        }
      }}
      placeholder={t('search')}
      aria-label={t('searchOptions')}
      className="mb-1 h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none"
    />
  )
}

export function Avatar({ option, size = 'sm' }: { option: OptionObject; size?: 'sm' | 'xs' }) {
  if (!option.avatar && !option.color) return null
  const cls = cn(
    'flex shrink-0 items-center justify-center overflow-hidden rounded-full font-medium',
    size === 'sm' ? 'size-6 text-[10px]' : 'size-4 text-[8px]',
  )
  if (option.avatar && /^(https?:|\/|data:)/.test(option.avatar)) {
    return <img src={option.avatar} alt="" className={cls} />
  }
  if (!option.avatar) {
    return (
      <span
        aria-hidden
        className="size-2 shrink-0 rounded-full"
        style={{ background: option.color }}
      />
    )
  }
  return (
    <span
      aria-hidden
      className={cn(cls, 'bg-muted text-foreground')}
      style={option.color ? { background: option.color, color: 'white' } : undefined}
    >
      {option.avatar}
    </span>
  )
}

export function OptionView({ option, wrap = false }: { option: OptionObject; wrap?: boolean }) {
  return (
    <span className="flex min-w-0 flex-1 items-center gap-2">
      <Avatar option={option} />
      <span className="flex min-w-0 flex-col">
        <span className={wrap ? 'break-words' : 'truncate'}>{option.label}</span>
        {option.description && (
          <span className="text-xs text-muted-foreground">{option.description}</span>
        )}
      </span>
    </span>
  )
}

const filterOptions = (options: OptionObject[], query: string) => {
  const q = query.toLowerCase()
  return options.filter(
    (o) => o.label.toLowerCase().includes(q) || o.description?.toLowerCase().includes(q),
  )
}

type BaseProps<T> = {
  id: string
  value: T
  disabled?: boolean
  invalid?: boolean
  describedBy?: string
  onChange: (value: T) => void
}

export function SelectField({
  id,
  value,
  options,
  placeholder,
  disabled,
  invalid,
  describedBy,
  onChange,
}: BaseProps<string> & { options: Option[]; placeholder?: string }) {
  const { t } = useFormConfig()
  const [query, setQuery] = useState('')
  const all = normalizeOptions(options)
  const shown = filterOptions(all, query)
  const selected = all.find((o) => o.value === value)

  return (
    <Popover
      block
      className="w-full"
      trigger={({ open, toggle }) => (
        <button
          id={id}
          type="button"
          aria-haspopup="listbox"
          aria-expanded={open}
          data-invalid={invalid || undefined}
          aria-describedby={describedBy}
          disabled={disabled}
          onClick={toggle}
          className={cn(triggerClass, 'data-invalid:border-destructive')}
        >
          {selected ? (
            <span className="flex min-w-0 items-center gap-2">
              <Avatar option={selected} size="xs" />
              <span className="truncate">{selected.label}</span>
            </span>
          ) : (
            <span className="truncate text-muted-foreground">
              {value || (placeholder ?? t('select'))}
            </span>
          )}
          {!disabled && chevron}
        </button>
      )}
    >
      {(close) => (
        <>
          {all.length > 7 && <OptionSearch value={query} onChange={setQuery} />}
          <ul role="listbox" className="max-h-64 overflow-y-auto">
            {shown.map((option) => (
              <li key={option.value}>
                <button
                  type="button"
                  role="option"
                  aria-selected={option.value === value}
                  onClick={() => {
                    onChange(option.value === value ? '' : option.value)
                    setQuery('')
                    close()
                  }}
                  className={optionRow}
                >
                  <OptionView option={option} />
                  {option.value === value && <span aria-hidden>✓</span>}
                </button>
              </li>
            ))}
            {!shown.length && (
              <li className="px-2 py-1.5 text-sm text-muted-foreground">{t('noMatchesShort')}</li>
            )}
          </ul>
        </>
      )}
    </Popover>
  )
}

export function Check({ on, round = false }: { on: boolean; round?: boolean }) {
  return (
    <span
      aria-hidden
      className={cn(
        'flex size-4 shrink-0 items-center justify-center border text-[10px] transition-colors',
        round ? 'rounded-full' : 'rounded',
        on ? 'border-primary bg-primary text-primary-foreground' : 'border-input bg-card',
      )}
    >
      {on && (round ? <span className="size-1.5 rounded-full bg-current" /> : '✓')}
    </span>
  )
}

export function MultiSelectField({
  id,
  value,
  options,
  creatable = false,
  display,
  placeholder,
  disabled,
  invalid,
  describedBy,
  onChange,
}: BaseProps<string[]> & {
  options: Option[]
  creatable?: boolean
  display?: 'checkboxes' | string
  placeholder?: string
}) {
  const { t } = useFormConfig()
  const [query, setQuery] = useState('')
  const base = normalizeOptions(options)
  const all = [
    ...base,
    ...value.filter((v) => !base.some((o) => o.value === v)).map((v) => ({ value: v, label: v })),
  ]
  const shown = filterOptions(all, query)
  const toggle = (option: string) =>
    onChange(value.includes(option) ? value.filter((v) => v !== option) : [...value, option])

  if (display === 'checkboxes') {
    return (
      <div
        id={id}
        role="group"
        aria-describedby={describedBy}
        className="grid grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-x-3 gap-y-1.5"
      >
        {all.map((option) => {
          const on = value.includes(option.value)
          return (
            <button
              key={option.value}
              type="button"
              role="checkbox"
              aria-checked={on}
              disabled={disabled}
              onClick={() => toggle(option.value)}
              className="flex items-start gap-2 rounded-lg py-1 text-left text-sm disabled:cursor-default"
            >
              <span className="pt-0.5">
                <Check on={on} />
              </span>
              <OptionView option={option} wrap />
            </button>
          )
        })}
      </div>
    )
  }

  const create = () => {
    const text = query.trim()
    if (!creatable || !text || all.some((o) => o.label === text)) return
    onChange([...value, text])
    setQuery('')
  }

  return (
    <Popover
      block
      className="w-full"
      trigger={({ open, toggle: toggleOpen }) => (
        <div
          id={id}
          role="button"
          tabIndex={disabled ? -1 : 0}
          aria-haspopup="listbox"
          aria-expanded={open}
          aria-disabled={disabled || undefined}
          data-invalid={invalid || undefined}
          aria-describedby={describedBy}
          onClick={() => !disabled && toggleOpen()}
          onKeyDown={(e) => !disabled && (e.key === 'Enter' || e.key === ' ') && toggleOpen()}
          className={cn(triggerClass, 'flex-wrap justify-start data-invalid:border-destructive')}
        >
          {value.map((v) => {
            const option = all.find((o) => o.value === v) ?? { value: v, label: v }
            return (
              <span
                key={v}
                className="flex items-center gap-1 rounded-md bg-muted py-0.5 pr-1 pl-1.5 text-xs"
              >
                <Avatar option={option} size="xs" />
                {option.label}
                {!disabled && (
                  <button
                    type="button"
                    aria-label={t('removeOption', { label: option.label })}
                    onClick={(e) => {
                      e.stopPropagation()
                      toggle(v)
                    }}
                    className="px-0.5 text-muted-foreground hover:text-foreground"
                  >
                    ✕
                  </button>
                )}
              </span>
            )
          })}
          {!value.length && (
            <span className="text-muted-foreground">{placeholder ?? t('add')}</span>
          )}
          {!disabled && <span className="ml-auto">{chevron}</span>}
        </div>
      )}
    >
      <OptionSearch value={query} onChange={setQuery} onEnter={create} />
      <ul role="listbox" aria-multiselectable className="max-h-64 overflow-y-auto">
        {shown.map((option) => (
          <li key={option.value}>
            <button
              type="button"
              role="option"
              aria-selected={value.includes(option.value)}
              onClick={() => toggle(option.value)}
              className={optionRow}
            >
              <Check on={value.includes(option.value)} />
              <OptionView option={option} />
            </button>
          </li>
        ))}
        {creatable && query.trim() && !all.some((o) => o.label === query.trim()) && (
          <li>
            <button type="button" onClick={create} className={optionRow}>
              {t('createOption', { value: query.trim() })}
            </button>
          </li>
        )}
      </ul>
    </Popover>
  )
}

export function ToggleField({
  id,
  value,
  label,
  disabled,
  describedBy,
  onChange,
}: BaseProps<boolean> & { label?: string }) {
  return (
    <button
      id={id}
      type="button"
      role="switch"
      aria-checked={value}
      aria-describedby={describedBy}
      disabled={disabled}
      onClick={() => onChange(!value)}
      className="flex min-h-8 items-center gap-2.5 text-sm disabled:cursor-default disabled:opacity-70"
    >
      <span
        className={cn(
          'relative h-5 w-9 shrink-0 rounded-full transition-colors',
          value ? 'bg-primary' : 'bg-input',
        )}
      >
        <span
          className={cn(
            'absolute top-0.5 left-0.5 size-4 rounded-full bg-card shadow transition-transform',
            value && 'translate-x-4',
          )}
        />
      </span>
      {label}
    </button>
  )
}

export function RadioField({
  id,
  value,
  options,
  display,
  disabled,
  describedBy,
  onChange,
}: BaseProps<string> & { options: Option[]; display?: string }) {
  const all = normalizeOptions(options)
  const cards = display === 'cards' || (display !== 'segmented' && all.some((o) => o.description))

  if (cards) {
    return (
      <div
        id={id}
        role="radiogroup"
        aria-describedby={describedBy}
        className="flex flex-col gap-1.5"
      >
        {all.map((option) => {
          const on = value === option.value
          return (
            <button
              key={option.value}
              type="button"
              role="radio"
              aria-checked={on}
              disabled={disabled}
              onClick={() => onChange(option.value)}
              className={cn(
                'flex items-start gap-2.5 rounded-lg border p-2.5 text-left text-sm transition-colors disabled:cursor-default',
                on ? 'border-foreground bg-muted/50' : 'hover:border-ring',
              )}
            >
              <span className="pt-0.5">
                <Check on={on} round />
              </span>
              <span className="flex min-w-0 flex-col gap-0.5">
                <span className="font-medium">{option.label}</span>
                {option.description && (
                  <span className="text-xs text-muted-foreground">{option.description}</span>
                )}
              </span>
            </button>
          )
        })}
      </div>
    )
  }

  return (
    <div
      id={id}
      role="radiogroup"
      aria-describedby={describedBy}
      className="flex rounded-lg bg-muted p-0.5"
    >
      {all.map((option) => (
        <button
          key={option.value}
          type="button"
          role="radio"
          aria-checked={value === option.value}
          disabled={disabled}
          onClick={() => onChange(option.value)}
          className={cn(
            'h-7 flex-1 truncate rounded-md px-2 text-sm transition-colors disabled:cursor-default',
            value === option.value
              ? 'bg-card font-medium text-foreground shadow-(--shadow-button)'
              : 'text-muted-foreground enabled:hover:text-foreground',
          )}
        >
          {option.label}
        </button>
      ))}
    </div>
  )
}

export function ListField({
  id,
  value,
  placeholder,
  disabled,
  describedBy,
  onChange,
}: BaseProps<string[]> & { placeholder?: string }) {
  const { t } = useFormConfig()
  const [draft, setDraft] = useState('')
  const inputs = useRef<(HTMLInputElement | null)[]>([])
  const add = () => {
    const text = draft.trim()
    if (!text) return
    onChange([...value, text])
    setDraft('')
  }

  return (
    <div id={id} aria-describedby={describedBy} className="flex flex-col gap-1.5">
      {value.map((item, i) => (
        <div key={i} className="flex items-center gap-2">
          <span className="w-4 text-right text-xs text-muted-foreground tabular-nums">{i + 1}</span>
          <input
            ref={(el) => {
              inputs.current[i] = el
            }}
            value={item}
            readOnly={disabled}
            aria-label={t('itemN', { n: i + 1 })}
            onChange={(e) => onChange(value.map((v, j) => (j === i ? e.target.value : v)))}
            onKeyDown={(e) => {
              if (e.key === 'Backspace' && !item) {
                e.preventDefault()
                onChange(value.filter((_, j) => j !== i))
                inputs.current[i - 1]?.focus()
              }
            }}
            className="h-8 min-w-0 flex-1 rounded-lg border border-input bg-card px-2.5 text-sm outline-none read-only:bg-muted/40 focus:border-ring"
          />
          {!disabled && (
            <button
              type="button"
              aria-label={t('removeItemN', { n: i + 1 })}
              onClick={() => onChange(value.filter((_, j) => j !== i))}
              className="flex size-8 items-center justify-center rounded-lg text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
            >
              ✕
            </button>
          )}
        </div>
      ))}
      {!disabled && (
        <div className="flex items-center gap-2">
          <span className="w-4 text-right text-xs text-muted-foreground">+</span>
          <input
            value={draft}
            placeholder={placeholder ?? t('listPlaceholder')}
            aria-label={t('newItem')}
            onChange={(e) => setDraft(e.target.value)}
            onBlur={add}
            onKeyDown={(e) => {
              if (e.key === 'Enter') {
                e.preventDefault()
                add()
              }
            }}
            className="h-8 min-w-0 flex-1 rounded-lg border border-dashed border-input bg-transparent px-2.5 text-sm outline-none placeholder:text-muted-foreground focus:border-solid focus:border-ring"
          />
        </div>
      )}
    </div>
  )
}
dates.ts
import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import localizedFormat from 'dayjs/plugin/localizedFormat'

dayjs.extend(customParseFormat)
dayjs.extend(localizedFormat)

export { dayjs }

const ISO_DATE = 'YYYY-MM-DD'
const ISO_MONTH = 'YYYY-MM'
const ISO_TIME = 'HH:mm'
const ISO_DATETIME = 'YYYY-MM-DDTHH:mm'

const DATE_INPUTS = [
  ISO_DATE,
  'D/M/YYYY',
  'DD/MM/YYYY',
  'M/D/YYYY',
  'MM/DD/YYYY',
  'D.M.YYYY',
  'D MMM YYYY',
  'D MMMM YYYY',
  'MMM D YYYY',
  'MMM D, YYYY',
  'MMMM D YYYY',
  'MMMM D, YYYY',
]

const local = (value: dayjs.ConfigType, format?: string, locale?: string) => {
  const d = format ? dayjs(value, format, true) : dayjs(value)
  return locale ? d.locale(locale) : d
}

export const todayIso = () => dayjs().format(ISO_DATE)
export const thisMonthIso = () => dayjs().format(ISO_MONTH)

export function formatDate(iso: string, locale?: string) {
  const d = local(iso, ISO_DATE, locale)
  return d.isValid() ? d.format('ll') : iso
}

export function formatMonth(iso: string, locale?: string) {
  const d = local(iso, ISO_MONTH, locale)
  return d.isValid() ? d.format('MMM YYYY') : iso
}

export function formatTime(hhmm: string, locale?: string) {
  const d = local(hhmm, ISO_TIME, locale)
  return d.isValid() ? d.format('LT') : hhmm
}

export function formatDateTime(value: string, locale?: string) {
  const d = local(value, ISO_DATETIME, locale)
  if (d.isValid()) return d.format('lll')
  return formatDate(value.slice(0, 10), locale)
}

export function formatDateRange(value: string, locale?: string) {
  const [a, b] = value.split('/')
  if (!a) return ''
  return b ? `${formatDate(a, locale)} – ${formatDate(b, locale)}` : formatDate(a, locale)
}

export function formatRelative(date: Date, locale?: string) {
  const d = local(date, undefined, locale)
  const minutes = dayjs().diff(d, 'minute')
  if (minutes < 1) return d.format('LT')
  if (dayjs().isSame(d, 'day')) return d.format('LT')
  return d.format('lll')
}

export function parseDateInput(text: string, locale?: string): string | null {
  const t = text.trim()
  if (!t) return null
  for (const format of DATE_INPUTS) {
    const d = locale ? dayjs(t, format, locale, true) : dayjs(t, format, true)
    if (d.isValid()) return d.format(ISO_DATE)
  }
  return null
}

export const isIsoDate = (value: string) => dayjs(value, ISO_DATE, true).isValid()
export const isIsoDateTime = (value: string) => dayjs(value, ISO_DATETIME, true).isValid()
drafts.tsx
'use client'

import { useCallback, useEffect, useRef, useState } from 'react'
import { AlertCircle, Check, FileClock, Loader2 } from 'lucide-react'
import { useFormState, useWatch, type UseFormReturn } from 'react-hook-form'

import { cn } from '@/lib/utils'

import { useFormConfig } from './context'
import { formatRelative } from './dates'
import type { FormValues } from './schema'

export type DraftState = { savedAt: Date; restore: () => void; discard: () => void }

export type AutosaveState = {
  status: 'idle' | 'pending' | 'saving' | 'saved' | 'error'
  savedAt?: Date
  error?: string
  retry: () => void
}

type StoredDraft = { values: FormValues; savedAt: string }

const storageKey = (key: string) => `record-form:draft:${key}`

export function readDraft(key: string): { values: FormValues; savedAt: Date } | null {
  try {
    const raw = window.localStorage.getItem(storageKey(key))
    if (!raw) return null
    const parsed = JSON.parse(raw) as StoredDraft
    return { values: parsed.values, savedAt: new Date(parsed.savedAt) }
  } catch {
    return null
  }
}

export function writeDraft(key: string, values: FormValues) {
  try {
    const stored: StoredDraft = { values, savedAt: new Date().toISOString() }
    window.localStorage.setItem(storageKey(key), JSON.stringify(stored))
  } catch {}
}

export function clearDraft(key: string) {
  try {
    window.localStorage.removeItem(storageKey(key))
  } catch {}
}

export function useLeaveWarning(active: boolean, message?: string) {
  const { t } = useFormConfig()
  const text = message ?? t('leaveWarning')
  useEffect(() => {
    if (!active) return
    const onBeforeUnload = (event: BeforeUnloadEvent) => {
      event.preventDefault()
      event.returnValue = text
      return text
    }
    window.addEventListener('beforeunload', onBeforeUnload)
    return () => window.removeEventListener('beforeunload', onBeforeUnload)
  }, [active, text])
}

export function useDraft(form: UseFormReturn<FormValues>, key?: string): DraftState | null {
  const values = useWatch({ control: form.control }) as FormValues
  const { isDirty } = useFormState({ control: form.control })
  const [found, setFound] = useState<{ values: FormValues; savedAt: Date } | null>(null)

  useEffect(() => {
    if (!key) return
    const draft = readDraft(key)
    if (draft && JSON.stringify(draft.values) !== JSON.stringify(form.getValues()))
      queueMicrotask(() => setFound(draft))
  }, [key, form])

  useEffect(() => {
    if (!key || found) return
    if (!isDirty) return clearDraft(key)
    const timer = setTimeout(() => writeDraft(key, values), 600)
    return () => clearTimeout(timer)
  }, [key, found, isDirty, values])

  if (!key || !found) return null
  return {
    savedAt: found.savedAt,
    restore: () => {
      form.reset(found.values, { keepDefaultValues: true })
      setFound(null)
    },
    discard: () => {
      clearDraft(key)
      setFound(null)
    },
  }
}

export type AutosaveOptions = { save: (values: FormValues) => Promise<void>; delay?: number }

export function useAutosave(
  form: UseFormReturn<FormValues>,
  options?: AutosaveOptions,
): AutosaveState | undefined {
  const values = useWatch({ control: form.control })
  const { isDirty } = useFormState({ control: form.control })
  const [state, setState] = useState<Omit<AutosaveState, 'retry'>>({ status: 'idle' })
  const latest = useRef(options)
  useEffect(() => {
    latest.current = options
  })

  const saveNow = useCallback(async () => {
    const save = latest.current?.save
    if (!save) return
    const snapshot = form.getValues()
    setState((s) => ({ ...s, status: 'saving' }))
    try {
      await save(snapshot)
      form.reset(snapshot, {
        keepValues: true,
        keepErrors: true,
        keepTouched: true,
        keepIsSubmitted: true,
      })
      setState({ status: 'saved', savedAt: new Date() })
    } catch (error) {
      setState({ status: 'error', error: error instanceof Error ? error.message : undefined })
    }
  }, [form])

  const enabled = !!options
  const delay = options?.delay ?? 1500
  useEffect(() => {
    if (!enabled || !isDirty) return
    queueMicrotask(() => setState((s) => (s.status === 'saving' ? s : { ...s, status: 'pending' })))
    const timer = setTimeout(() => void saveNow(), delay)
    return () => clearTimeout(timer)
  }, [enabled, isDirty, values, delay, saveNow])

  return enabled ? { ...state, retry: () => void saveNow() } : undefined
}

export function DraftBanner({ draft, className }: { draft: DraftState; className?: string }) {
  const { t, locale } = useFormConfig()
  return (
    <div
      role="status"
      className={cn(
        'mb-3 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-xl bg-muted px-3 py-2.5 text-sm [&_svg]:size-4',
        className,
      )}
    >
      <FileClock className="shrink-0 text-muted-foreground" />
      <span className="min-w-0 flex-1">
        {t('draftFound', { time: formatRelative(draft.savedAt, locale) })}
      </span>
      <span className="flex gap-1.5">
        <button
          type="button"
          onClick={draft.discard}
          className="h-7 rounded-lg px-2.5 text-xs font-medium text-muted-foreground hover:bg-card hover:text-foreground"
        >
          {t('discard')}
        </button>
        <button
          type="button"
          onClick={draft.restore}
          className="h-7 rounded-lg bg-primary px-2.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
        >
          {t('restore')}
        </button>
      </span>
    </div>
  )
}

export function SaveStatus({ state, className }: { state: AutosaveState; className?: string }) {
  const { t, locale } = useFormConfig()
  if (state.status === 'idle') return null
  return (
    <span
      role="status"
      aria-live="polite"
      className={cn(
        'inline-flex items-center gap-1.5 text-xs [&_svg]:size-3.5',
        state.status === 'error' ? 'text-destructive' : 'text-muted-foreground',
        className,
      )}
    >
      {state.status === 'saving' && (
        <>
          <Loader2 className="animate-spin motion-reduce:animate-none" /> {t('autosaving')}
        </>
      )}
      {state.status === 'pending' && t('unsaved')}
      {state.status === 'saved' && (
        <>
          <Check />
          {state.savedAt
            ? t('autosavedAt', { time: formatRelative(state.savedAt, locale) })
            : t('autosaved')}
        </>
      )}
      {state.status === 'error' && (
        <>
          <AlertCircle /> {t('autosaveFailed')}
          <button
            type="button"
            onClick={state.retry}
            className="font-medium underline-offset-2 hover:underline"
          >
            {t('retry')}
          </button>
        </>
      )}
    </span>
  )
}
field-control.tsx
'use client'

import { useMemo } from 'react'

import { cn } from '@/lib/utils'
import { Input } from '@/registry/dashboard/components/input/input'

import { DatePicker } from '@/registry/dashboard/components/date-picker/date-picker'
import { pickerLabels, type PickerLabels } from '@/registry/dashboard/components/date-picker/locale'
import { DateRangeField } from '@/registry/dashboard/components/date-picker/date-range-field'
import { DateTimePicker } from '@/registry/dashboard/components/date-picker/date-time-picker'
import { MonthPicker } from '@/registry/dashboard/components/date-picker/month-picker'
import { TimePicker } from '@/registry/dashboard/components/date-picker/time-picker'

import { ListField, MultiSelectField, RadioField, SelectField, ToggleField } from './controls'
import { useFormConfig } from './context'
import { FileField } from './files'
import type { T } from './i18n'
import { GroupField } from './group'
import { AddressField, LookupField } from './lookup'
import { DurationField, NumberField } from './number-input'
import { PhoneField } from './phone'
import { RichTextField } from './rich-text'
import { RowsField } from './rows'
import { SignatureField } from './signature'
import type {
  CustomFieldType,
  FieldDef,
  FieldValue,
  FileItem,
  FormErrors,
  FormValues,
} from './schema'

export type FieldRenderProps = {
  id: string
  field: FieldDef
  value: FieldValue | undefined
  values: FormValues
  invalid: boolean
  disabled: boolean
  describedBy?: string
  onChange: (value: FieldValue) => void
}

export type FieldTypeRenderer = CustomFieldType & {
  render: (props: FieldRenderProps) => React.ReactNode
}

export type FieldControlProps = FieldRenderProps & {
  path?: string
  currency?: string
  errors?: FormErrors
  fieldTypes?: Record<string, FieldTypeRenderer>
}

const pickerAliases: Partial<Record<keyof PickerLabels, string>> = {
  today: 'today',
  now: 'now',
  thisMonth: 'thisMonth',
  clear: 'clear',
  clearValue: 'clear',
  pickDate: 'pickDate',
  pickTime: 'pickTime',
  pickMonth: 'pickMonth',
  pickDates: 'pickDates',
  pickDateTime: 'pickDateTime',
}

function pickerLabelsFrom(t: T): PickerLabels {
  return Object.fromEntries(
    (Object.keys(pickerLabels) as (keyof PickerLabels)[]).map((key) => {
      const own = t(`picker.${key}`)
      if (own !== `picker.${key}`) return [key, own]
      const alias = pickerAliases[key]
      return [key, alias ? t(alias) : pickerLabels[key]]
    }),
  ) as PickerLabels
}

function limit(value: number | string | undefined, type: 'date' | 'time' | 'month') {
  if (value === undefined) return undefined
  if (value !== 'today') return String(value)
  const d = new Date()
  const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
  return type === 'month' ? ymd.slice(0, 7) : type === 'date' ? ymd : undefined
}

const asText = (value: FieldValue | undefined) =>
  typeof value === 'string' ? value : typeof value === 'number' ? String(value) : ''
const asList = (value: FieldValue | undefined) => (Array.isArray(value) ? (value as string[]) : [])

export function FieldControl(props: FieldControlProps) {
  const {
    id,
    field,
    value,
    invalid,
    disabled,
    describedBy,
    onChange,
    currency = '$',
    fieldTypes = {},
  } = props
  const { t, locale } = useFormConfig()
  const labels = useMemo(() => pickerLabelsFrom(t), [t])
  const custom = fieldTypes[field.type]
  if (custom) return custom.render(props)

  const text = asText(value)
  const common = { id, disabled, invalid, describedBy }

  switch (field.type) {
    case 'textarea':
      return (
        <textarea
          id={id}
          value={text}
          readOnly={disabled}
          placeholder={field.placeholder}
          aria-invalid={invalid}
          aria-describedby={describedBy}
          onChange={(e) => onChange(e.target.value)}
          className="min-h-20 w-full rounded-lg border border-input bg-card px-2.5 py-2 text-sm leading-relaxed outline-none placeholder:text-muted-foreground read-only:bg-muted/40 focus:border-ring aria-invalid:border-destructive"
        />
      )
    case 'select':
      return (
        <SelectField
          {...common}
          value={text}
          options={field.options ?? []}
          placeholder={field.placeholder}
          onChange={onChange}
        />
      )
    case 'multiselect':
      return (
        <MultiSelectField
          {...common}
          value={asList(value)}
          options={field.options ?? []}
          creatable={field.creatable}
          display={field.display}
          placeholder={field.placeholder}
          onChange={onChange}
        />
      )
    case 'toggle':
      return (
        <ToggleField
          {...common}
          value={value === true}
          label={field.placeholder}
          onChange={onChange}
        />
      )
    case 'radio':
      return (
        <RadioField
          {...common}
          value={text}
          options={field.options ?? []}
          display={field.display}
          onChange={onChange}
        />
      )
    case 'date':
      return (
        <DatePicker
          {...common}
          value={text}
          min={limit(field.min, 'date')}
          max={limit(field.max, 'date')}
          placeholder={field.placeholder}
          clearable={!field.required}
          locale={locale}
          labels={labels}
          onChange={onChange}
        />
      )
    case 'time':
      return (
        <TimePicker
          {...common}
          value={text}
          step={field.step}
          min={limit(field.min, 'time')}
          max={limit(field.max, 'time')}
          placeholder={field.placeholder}
          clearable={!field.required}
          locale={locale}
          labels={labels}
          onChange={onChange}
        />
      )
    case 'month':
      return (
        <MonthPicker
          {...common}
          value={text}
          min={limit(field.min, 'month')}
          max={limit(field.max, 'month')}
          placeholder={field.placeholder}
          clearable={!field.required}
          locale={locale}
          labels={labels}
          onChange={onChange}
        />
      )
    case 'list':
      return (
        <ListField
          {...common}
          value={asList(value)}
          placeholder={field.placeholder}
          onChange={onChange}
        />
      )
    case 'file':
      return (
        <FileField
          {...common}
          value={Array.isArray(value) ? (value as FileItem[]) : []}
          accept={field.accept}
          multiple={field.multiple ?? true}
          maxSize={field.maxSize}
          maxFiles={field.maxLength}
          onChange={onChange}
        />
      )
    case 'rows':
      return <RowsField {...props} Control={FieldControl} />
    case 'group':
      return <GroupField {...props} Control={FieldControl} />
    case 'number':
    case 'currency':
    case 'percent':
      return (
        <NumberField
          {...common}
          value={text}
          decimals={field.decimals}
          placeholder={field.placeholder}
          leading={field.type === 'currency' ? currency.trim() : undefined}
          trailing={field.type === 'percent' ? '%' : undefined}
          onChange={onChange}
        />
      )
    case 'duration':
      return (
        <DurationField
          {...common}
          value={text}
          placeholder={field.placeholder}
          onChange={onChange}
        />
      )
    case 'phone':
      return (
        <PhoneField
          {...common}
          value={text}
          defaultCountry={field.defaultCountry}
          placeholder={field.placeholder}
          onChange={onChange}
        />
      )
    case 'lookup':
      return (
        <LookupField
          {...common}
          value={field.multiple ? asList(value) : text}
          multiple={field.multiple}
          search={field.search}
          options={field.options}
          placeholder={field.placeholder}
          onChange={onChange}
        />
      )
    case 'address':
      return (
        <AddressField
          {...common}
          value={text}
          search={field.search}
          placeholder={field.placeholder}
          onChange={onChange}
        />
      )
    case 'daterange':
      return (
        <DateRangeField
          {...common}
          value={text}
          min={limit(field.min, 'date')}
          max={limit(field.max, 'date')}
          placeholder={field.placeholder}
          clearable={!field.required}
          locale={locale}
          labels={labels}
          onChange={onChange}
        />
      )
    case 'datetime':
      return (
        <DateTimePicker
          {...common}
          value={text}
          step={field.step}
          min={field.min === undefined ? undefined : String(field.min)}
          max={field.max === undefined ? undefined : String(field.max)}
          placeholder={field.placeholder}
          clearable={!field.required}
          locale={locale}
          labels={labels}
          onChange={onChange}
        />
      )
    case 'richtext':
      return (
        <RichTextField
          {...common}
          value={text}
          placeholder={field.placeholder}
          onChange={onChange}
        />
      )
    case 'signature':
      return <SignatureField {...common} value={text} onChange={onChange} />
    default:
      return (
        <Input
          id={id}
          value={text}
          readOnly={disabled}
          placeholder={field.placeholder}
          aria-invalid={invalid}
          aria-describedby={describedBy}
          type={field.type === 'email' ? 'email' : 'text'}
          inputMode={field.type === 'url' ? 'url' : field.type === 'email' ? 'email' : undefined}
          onChange={(e) => onChange(e.target.value)}
          className={cn(disabled && 'bg-muted/40')}
        />
      )
  }
}
files.tsx
'use client'

import { useEffect, useRef, useState } from 'react'
import { RotateCw, Upload, X } from 'lucide-react'
import { useDropzone, type Accept, type FileRejection } from 'react-dropzone'

import { cn } from '@/lib/utils'
import { toast } from '@/registry/dashboard/components/toast/toast'

import { useFormConfig } from './context'
import { showSize, type FileItem } from './schema'

type Rejection = { file: File; reason: 'size' | 'type' | 'count' }

function toAccept(accept?: string): Accept | undefined {
  if (!accept) return undefined
  const result: Accept = {}
  const extensions: string[] = []
  for (const part of accept.split(',').map((a) => a.trim())) {
    if (part.startsWith('.')) extensions.push(part)
    else result[part] = []
  }
  if (extensions.length) result['application/octet-stream'] = extensions
  return result
}

const reasonOf = (r: FileRejection): Rejection['reason'] => {
  const code = r.errors[0]?.code
  return code === 'file-too-large' ? 'size' : code === 'too-many-files' ? 'count' : 'type'
}

const newId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`

export function FileField({
  id,
  value,
  accept,
  multiple = true,
  maxSize,
  maxFiles,
  disabled,
  invalid,
  describedBy,
  onChange,
}: {
  id: string
  value: FileItem[]
  accept?: string
  multiple?: boolean
  maxSize?: number
  maxFiles?: number
  disabled?: boolean
  invalid?: boolean
  describedBy?: string
  onChange: (value: FileItem[]) => void
}) {
  const { upload, t } = useFormConfig()
  const current = useRef(value)
  const sources = useRef(new Map<string, File>())
  const controllers = useRef(new Map<string, AbortController>())
  const [retryable, setRetryable] = useState<string[]>([])
  const [rejected, setRejected] = useState<string[]>([])

  useEffect(() => {
    current.current = value
  }, [value])

  useEffect(() => {
    const running = controllers.current
    return () => running.forEach((c) => c.abort())
  }, [])

  const commit = (next: FileItem[]) => {
    current.current = next
    onChange(next)
  }
  const patch = (fileId: string, change: Partial<FileItem>) =>
    commit(current.current.map((f) => (f.id === fileId ? { ...f, ...change } : f)))

  const start = (item: FileItem, file: File) => {
    if (!upload) return
    const controller = new AbortController()
    controllers.current.set(item.id, controller)
    let last = 0
    upload(file, {
      signal: controller.signal,
      onProgress: (p) => {
        const progress = Math.max(0, Math.min(99, Math.round(p)))
        if (progress - last < 2) return
        last = progress
        patch(item.id, { progress })
      },
    }).then(
      (result) => {
        controllers.current.delete(item.id)
        sources.current.delete(item.id)
        setRetryable((r) => r.filter((x) => x !== item.id))
        patch(item.id, { status: 'done', progress: 100, url: result.url, error: undefined })
      },
      (error: unknown) => {
        controllers.current.delete(item.id)
        if (error instanceof DOMException && error.name === 'AbortError') return
        setRetryable((r) => (r.includes(item.id) ? r : [...r, item.id]))
        patch(item.id, {
          status: 'error',
          error: error instanceof Error ? error.message : t('uploadError'),
        })
      },
    )
  }

  const add = (files: File[]) => {
    setRejected([])
    const items = files.map((file) => {
      const item: FileItem = {
        id: newId(),
        name: file.name,
        size: file.size,
        type: file.type,
        url: upload ? undefined : URL.createObjectURL(file),
        status: upload ? 'uploading' : 'done',
        progress: upload ? 0 : 100,
      }
      sources.current.set(item.id, file)
      return item
    })
    commit(multiple ? [...current.current, ...items] : items)
    items.forEach((item, i) => start(item, files[i]))
  }

  const reject = (rejections: Rejection[]) => {
    const messages = rejections.map((r) =>
      r.reason === 'count'
        ? t('rejectedCount')
        : t(r.reason === 'size' ? 'rejectedSize' : 'rejectedType', { name: r.file.name }),
    )
    const unique = [...new Set(messages)]
    setRejected(unique)
    if (toast.isMounted()) unique.forEach((m) => toast.error(m))
  }

  const retry = (item: FileItem) => {
    const file = sources.current.get(item.id)
    if (!file) return
    patch(item.id, { status: 'uploading', progress: 0, error: undefined })
    start({ ...item, status: 'uploading' }, file)
  }

  const remove = (item: FileItem) => {
    controllers.current.get(item.id)?.abort()
    controllers.current.delete(item.id)
    sources.current.delete(item.id)
    setRetryable((r) => r.filter((x) => x !== item.id))
    commit(current.current.filter((f) => f.id !== item.id))
  }

  const rules = [
    accept &&
      accept
        .split(',')
        .map((a) => {
          const k = a.trim().toLowerCase()
          if (k.endsWith('/*')) return `${k.slice(0, -2).replace(/^./, (c) => c.toUpperCase())}s`
          return (k.includes('/') ? k.split('/')[1] : k.replace(/^\./, '')).toUpperCase()
        })
        .join(', '),
    maxSize && t('upTo', { size: showSize(maxSize) }),
  ]
    .filter(Boolean)
    .join(' · ')

  const full = maxFiles !== undefined && value.length >= maxFiles

  return (
    <div className="flex flex-col gap-2">
      {!disabled && !full && (
        <DropArea
          id={id}
          accept={accept}
          multiple={multiple}
          maxSize={maxSize}
          maxFiles={maxFiles !== undefined ? Math.max(0, maxFiles - value.length) : undefined}
          invalid={invalid}
          describedBy={describedBy}
          rules={rules}
          onFiles={add}
          onReject={reject}
        />
      )}
      {rejected.length > 0 && !toast.isMounted() && (
        <ul className="text-xs text-destructive">
          {rejected.map((m) => (
            <li key={m}>{m}</li>
          ))}
        </ul>
      )}
      {value.length > 0 && (
        <ul className="flex flex-col divide-y rounded-lg border">
          {value.map((file) => {
            const uploading = file.status === 'uploading'
            const failed = file.status === 'error'
            return (
              <li key={file.id} className="flex flex-col gap-1.5 px-2.5 py-2 text-sm">
                <div className="flex items-center gap-2.5">
                  <span className="flex h-8 w-7 shrink-0 items-center justify-center rounded bg-muted font-mono text-[9px] text-muted-foreground uppercase">
                    {file.name.split('.').pop()?.slice(0, 4)}
                  </span>
                  <span className="flex min-w-0 flex-1 flex-col">
                    {file.url && !uploading ? (
                      <a
                        href={file.url}
                        target="_blank"
                        rel="noreferrer"
                        className="truncate hover:underline"
                      >
                        {file.name}
                      </a>
                    ) : (
                      <span className="truncate">{file.name}</span>
                    )}
                    <span
                      aria-live="polite"
                      className={cn(
                        'text-xs',
                        failed ? 'text-destructive' : 'text-muted-foreground',
                      )}
                    >
                      {failed
                        ? `${t('uploadError')}${file.error ? ` · ${file.error}` : ''}`
                        : uploading
                          ? t('uploadingPercent', { progress: file.progress ?? 0 })
                          : showSize(file.size)}
                    </span>
                  </span>
                  {failed && retryable.includes(file.id) && !disabled && (
                    <button
                      type="button"
                      onClick={() => retry(file)}
                      className="flex h-7 items-center gap-1 rounded-lg px-2 text-xs font-medium hover:bg-muted [&_svg]:size-3"
                    >
                      <RotateCw /> {t('retry')}
                    </button>
                  )}
                  {!disabled && (
                    <button
                      type="button"
                      aria-label={uploading ? t('cancelUpload') : t('remove', { name: file.name })}
                      title={uploading ? t('cancelUpload') : undefined}
                      onClick={() => remove(file)}
                      className="flex size-7 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5"
                    >
                      <X />
                    </button>
                  )}
                </div>
                {uploading && (
                  <div
                    role="progressbar"
                    aria-label={file.name}
                    aria-valuemin={0}
                    aria-valuemax={100}
                    aria-valuenow={file.progress ?? 0}
                    className="h-1 overflow-hidden rounded-full bg-muted"
                  >
                    <div
                      className="h-full rounded-full bg-primary transition-[width] duration-200 motion-reduce:transition-none"
                      style={{ width: `${file.progress ?? 0}%` }}
                    />
                  </div>
                )}
              </li>
            )
          })}
        </ul>
      )}
      {disabled && !value.length && (
        <span className="text-sm text-muted-foreground">{t('noFiles')}</span>
      )}
    </div>
  )
}

function DropArea({
  id,
  accept,
  multiple,
  maxSize,
  maxFiles,
  invalid,
  describedBy,
  rules,
  onFiles,
  onReject,
}: {
  id: string
  accept?: string
  multiple: boolean
  maxSize?: number
  maxFiles?: number
  invalid?: boolean
  describedBy?: string
  rules: string
  onFiles: (files: File[]) => void
  onReject: (rejections: Rejection[]) => void
}) {
  const { t } = useFormConfig()
  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    accept: toAccept(accept),
    multiple,
    maxSize,
    maxFiles: multiple ? (maxFiles ?? 0) : 1,
    onDrop: (accepted, rejections) => {
      if (accepted.length) onFiles(accepted)
      if (rejections.length)
        onReject(rejections.map((r) => ({ file: r.file, reason: reasonOf(r) })))
    },
  })
  return (
    <div
      {...getRootProps()}
      id={id}
      role="button"
      tabIndex={0}
      aria-describedby={describedBy}
      data-invalid={invalid || undefined}
      className={cn(
        'flex cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border border-dashed px-3 py-4 text-center text-sm transition-colors outline-none focus-visible:border-ring data-invalid:border-destructive',
        isDragActive ? 'border-ring bg-muted' : 'border-input hover:border-ring',
      )}
    >
      <input {...getInputProps()} />
      <Upload className="size-4 text-muted-foreground" />
      {isDragActive ? (
        <span className="font-medium">{t('dropNow')}</span>
      ) : (
        <span>
          <span className="font-medium">{t(multiple ? 'chooseFiles' : 'chooseFile')}</span>{' '}
          <span className="text-muted-foreground">{t(multiple ? 'orDropMany' : 'orDropOne')}</span>
        </span>
      )}
      {rules && <span className="text-xs text-muted-foreground">{rules}</span>}
    </div>
  )
}
group.tsx
'use client'

import { useEffect, useRef, useState } from 'react'
import { ChevronDown, Plus } from 'lucide-react'

import { cn } from '@/lib/utils'
import { Field } from '@/registry/dashboard/components/input/input'

import { useFormConfig } from './context'
import { applyAction, ItemMenu, newItem, UndoBar, useUndo, type ItemAction } from './items'
import { hasErrorUnder, type NestedProps } from './rows'
import {
  formatValue,
  isReadOnly,
  isRequired,
  isVisible,
  scope,
  type FieldDef,
  type FieldValue,
  type Row,
} from './schema'

const gridCols = {
  1: '',
  2: '@sm:grid-cols-2',
  3: '@sm:grid-cols-2 @xl:grid-cols-3',
}

const wideTypes = new Set(['rows', 'group', 'textarea', 'richtext', 'signature', 'file'])

export function GroupField({ Control, ...props }: NestedProps) {
  const {
    id,
    field,
    value,
    values,
    disabled,
    describedBy,
    onChange,
    currency,
    errors = {},
    fieldTypes,
    path = field.name,
  } = props
  const fields = field.fields ?? []
  const items = Array.isArray(value) ? (value as Row[]) : []
  const { t, roles, locale } = useFormConfig()
  const noun = field.itemLabel ?? t('noun.item_one').replace(/^./, (c) => c.toUpperCase())
  const nounPlural = (field.itemLabelPlural ?? `${noun}s`).toLowerCase()
  const canAdd = !disabled && (field.maxLength === undefined || items.length < field.maxLength)
  const [collapsed, setCollapsed] = useState<string[]>([])
  const wrap = useRef<HTMLDivElement>(null)
  const pendingFocus = useRef<number | null>(null)
  const { undo, offer, dismiss } = useUndo()

  useEffect(() => {
    const index = pendingFocus.current
    if (index === null) return
    pendingFocus.current = null
    wrap.current
      ?.querySelector<HTMLElement>(
        `[data-item="${index}"] [data-body] :is(input:not([type=file]), textarea, button, [role=combobox])`,
      )
      ?.focus()
  }, [value])

  const blank = () => newItem(fields)
  const setValue = (i: number, name: string, v: FieldValue) =>
    onChange(items.map((item, j) => (j === i ? { ...item, [name]: v } : item)))

  const act = (i: number, action: ItemAction) => {
    if (action === 'delete') {
      const before = items
      onChange(items.filter((_, j) => j !== i))
      return offer(t('itemDeleted', { label: noun, n: i + 1 }), () => onChange(before))
    }
    const result = applyAction(items, i, action, blank)
    pendingFocus.current = result.focus
    onChange(result.list)
    if (action === 'up' || action === 'down') return
    setCollapsed((c) => c.filter((x) => x !== result.list[result.focus].id))
  }

  const title = (item: Row, i: number) => {
    const titleField = fields.find((f) => f.name === field.titleField)
    const text = titleField
      ? formatValue(titleField, item[titleField.name], fieldTypes, { t, locale })
      : t('empty')
    return text !== t('empty') ? text : `${noun} ${i + 1}`
  }

  const subtitle = (item: Row) =>
    fields
      .filter(
        (f) => f.name !== field.titleField && !wideTypes.has(f.type) && f.type !== 'signature',
      )
      .map((f) =>
        formatValue(f, f.compute ? f.compute(scope(values, item)) : item[f.name], fieldTypes, {
          t,
          locale,
        }),
      )
      .filter((text) => text !== t('empty'))
      .slice(0, 3)
      .join(' · ')

  const body = (item: Row, i: number) => {
    const ctx = scope(values, item)
    const columns = field.groupColumns ?? 2
    return (
      <div data-body className="@container">
        <div className={cn('grid gap-4 [&>*]:min-w-0', gridCols[columns])}>
          {fields.map((f: FieldDef) => {
            if (!isVisible(f, ctx, roles)) return null
            const key = `${path}.${i}.${f.name}`
            const error = errors[key]
            return (
              <Field
                key={f.name}
                label={f.label}
                htmlFor={`field-${key}`}
                hint={f.hint ?? (f.compute ? t('calculated') : undefined)}
                error={error}
                required={!disabled && isRequired(f, ctx)}
                className={cn(wideTypes.has(f.type) && 'col-span-full')}
              >
                <Control
                  id={`field-${key}`}
                  path={key}
                  field={f}
                  value={f.compute ? f.compute(ctx) : item[f.name]}
                  values={ctx}
                  invalid={!!error}
                  disabled={!!disabled || isReadOnly(f, ctx, roles)}
                  describedBy={
                    error ? `field-${key}-error` : f.hint ? `field-${key}-hint` : undefined
                  }
                  currency={currency}
                  errors={errors}
                  fieldTypes={fieldTypes}
                  onChange={(v) => setValue(i, f.name, v)}
                />
              </Field>
            )
          })}
        </div>
      </div>
    )
  }

  return (
    <div ref={wrap} id={id} aria-describedby={describedBy} className="flex min-w-0 flex-col gap-2">
      {items.map((item, i) => {
        const failing = hasErrorUnder(errors, `${path}.${i}`)
        const open = !collapsed.includes(item.id) || failing
        return (
          <section
            key={item.id}
            data-item={i}
            className={cn(
              'rounded-xl border bg-card transition-colors',
              failing ? 'border-destructive/60' : 'border-border',
            )}
            onKeyDown={(e) => {
              if (!e.altKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return
              e.preventDefault()
              e.stopPropagation()
              act(i, e.key === 'ArrowUp' ? 'up' : 'down')
            }}
          >
            <header className="flex items-center gap-2 px-3 py-2">
              <button
                type="button"
                aria-expanded={open}
                onClick={() =>
                  setCollapsed((c) =>
                    c.includes(item.id) ? c.filter((x) => x !== item.id) : [...c, item.id],
                  )
                }
                className="flex min-w-0 flex-1 items-center gap-2 text-left"
              >
                <ChevronDown
                  className={cn(
                    'size-4 shrink-0 text-muted-foreground transition-transform',
                    !open && '-rotate-90',
                  )}
                />
                <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[11px] font-medium tabular-nums">
                  {i + 1}
                </span>
                <span className="min-w-0 flex-1">
                  <span className="block truncate text-sm font-medium">{title(item, i)}</span>
                  {!open && subtitle(item) && (
                    <span className="block truncate text-xs text-muted-foreground">
                      {subtitle(item)}
                    </span>
                  )}
                </span>
                {failing && (
                  <span className="shrink-0 text-xs text-destructive">{t('needsAttention')}</span>
                )}
              </button>
              {!disabled && (
                <ItemMenu
                  label={title(item, i)}
                  index={i}
                  count={items.length}
                  canAdd={canAdd}
                  onAction={(a) => act(i, a)}
                />
              )}
            </header>
            {open && <div className="border-t px-3 py-3">{body(item, i)}</div>}
          </section>
        )
      })}

      {!items.length && (
        <div className="rounded-xl border border-dashed px-3 py-6 text-center text-sm text-muted-foreground">
          {t('noItems', { label: nounPlural })}
        </div>
      )}

      {undo && (
        <UndoBar
          message={undo.message}
          onUndo={() => {
            undo.restore()
            dismiss()
          }}
          onDismiss={dismiss}
        />
      )}

      {canAdd && (
        <div className="flex items-center gap-2">
          <button
            type="button"
            onClick={() => {
              pendingFocus.current = items.length
              onChange([...items, blank()])
            }}
            className="flex h-8 items-center gap-1.5 rounded-lg border border-dashed px-3 text-sm font-medium text-muted-foreground hover:border-ring hover:text-foreground [&_svg]:size-3.5"
          >
            <Plus /> {field.addLabel ?? t('addItem', { label: noun.toLowerCase() })}
          </button>
          {items.length > 1 && (
            <button
              type="button"
              onClick={() => setCollapsed(collapsed.length ? [] : items.map((x) => x.id))}
              className="h-8 rounded-lg px-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground"
            >
              {collapsed.length ? t('expandAll') : t('collapseAll')}
            </button>
          )}
        </div>
      )}
    </div>
  )
}
i18n.ts
export const en = {
  required: '{label} is required',
  email: 'Enter a valid email',
  url: 'Enter a valid URL',
  number: 'Enter a number',
  phone: 'Enter a valid phone number',
  duration: 'Enter a duration like 1h 30m',
  pattern: 'Invalid format',
  minNumber: 'Must be at least {min}',
  maxNumber: 'Must be at most {max}',
  minDate: 'Must be on or after {min}',
  maxDate: 'Must be on or before {max}',
  minTime: 'Must be at or after {min}',
  maxTime: 'Must be at or before {max}',
  minChars_one: 'Use at least {count} character',
  minChars_other: 'Use at least {count} characters',
  maxChars_one: 'Use at most {count} character',
  maxChars_other: 'Use at most {count} characters',
  minCount: 'Add at least {count} {noun}',
  maxCount: 'Use at most {count} {noun}',
  maxSize: '{name} is over {max}',
  accept: '{name} isn’t an accepted file type',
  sumEquals: '{label} must add up to {equals} (now {total})',
  sumMin: '{label} must total at least {min}',
  sumMax: '{label} must total at most {max}',
  rangeIncomplete: 'Pick a start and an end date',
  rangeOrder: 'The end date is before the start date',
  rangeMin: 'Must start on or after {min}',
  rangeMax: 'Must end on or before {max}',
  datetime: 'Pick a date and a time',
  uploading: 'Wait for the upload to finish',
  uploadFailed: 'Some files failed to upload',

  'noun.row_one': 'row',
  'noun.row_other': 'rows',
  'noun.item_one': 'item',
  'noun.item_other': 'items',
  'noun.option_one': 'option',
  'noun.option_other': 'options',
  'noun.file_one': 'file',
  'noun.file_other': 'files',

  yes: 'Yes',
  no: 'No',
  empty: '—',
  signed: 'Signed',
  notSigned: 'Not signed',
  rows_one: '{count} row',
  rows_other: '{count} rows',
  searchFields: 'Search fields',
  clearSearch: 'Clear search',
  noFieldsMatch: 'No fields match “{query}”',
  needsAttention: 'Needs attention',
  calculated: 'Calculated automatically',
  clickToEdit: 'Click to edit',
  editField: 'Edit {label}',
  done: 'Done',
  cancel: 'Cancel',
  edit: 'Edit',
  next: 'Next',
  back: 'Back',
  review: 'Review',
  submit: 'Submit',
  saving: 'Saving…',
  checking: 'Checking…',
  stepOf: 'Step {step} of {total}',

  addRow: 'Add row',
  noRows: 'No rows yet',
  noRowsHint: 'No rows yet — add one below or paste from a spreadsheet',
  pasteTip: 'Tip: paste from Excel or Sheets · Enter on the last row adds a row',
  pasted_one: 'Pasted {count} cell',
  pasted_other: 'Pasted {count} cells',
  rowsAdded_one: 'added {count} row',
  rowsAdded_other: 'added {count} rows',
  rowLabel: 'Row {n}',
  rowDeleted: 'Row {n} deleted',
  rowsDeleted: '{count} rows deleted',
  itemDeleted: '{label} {n} deleted',
  selected: '{count} selected',
  selectAll: 'Select all rows',
  selectRow: 'Select row {n}',
  dragRow: 'Drag row {n} to reorder (or Alt + arrow keys)',
  showDetails: 'Show details for row {n}',
  hideDetails: 'Hide details for row {n}',
  actionsFor: 'Actions for {label}',
  insertAbove: 'Insert above',
  insertBelow: 'Insert below',
  duplicate: 'Duplicate',
  moveUp: 'Move up',
  moveDown: 'Move down',
  delete: 'Delete',
  clear: 'Clear',
  undo: 'Undo',
  dismiss: 'Dismiss',
  addItem: 'Add {label}',
  noItems: 'No {label} yet',
  expandAll: 'Expand all',
  collapseAll: 'Collapse all',

  chooseFile: 'Choose a file',
  chooseFiles: 'Choose files',
  orDropOne: 'or drop it here',
  orDropMany: 'or drop them here',
  dropNow: 'Drop to upload',
  upTo: 'up to {size}',
  noFiles: 'No files',
  uploadingPercent: 'Uploading… {progress}%',
  uploadError: 'Upload failed',
  retry: 'Retry',
  cancelUpload: 'Cancel upload',
  remove: 'Remove {name}',
  rejectedSize: '{name} is too large',
  rejectedType: '{name} isn’t an accepted file type',
  rejectedCount: 'Too many files',

  searching: 'Searching…',
  updating: 'Updating…',
  typeToSearch: 'Type to search',
  startTyping: 'Start typing to search',
  noMatches: 'No matches for “{query}”',
  loadFailed: 'Couldn’t load results.',
  tryAgain: 'Try again',

  today: 'Today',
  now: 'Now',
  thisMonth: 'This month',
  pickDate: 'Pick a date',
  pickTime: 'Pick a time',
  pickMonth: 'Pick a month',
  pickDates: 'Pick dates',
  pickDateTime: 'Pick a date and time',

  draftFound: 'You have an unsaved draft from {time}.',
  restore: 'Restore',
  discard: 'Discard',
  autosaving: 'Saving…',
  autosaved: 'All changes saved',
  autosavedAt: 'Saved {time}',
  autosaveFailed: 'Couldn’t save',
  unsaved: 'Unsaved changes',
  leaveWarning: 'You have unsaved changes. Leave anyway?',

  search: 'Search',
  searchOptions: 'Search options',
  select: 'Select',
  add: 'Add',
  createOption: 'Create “{value}”',
  noMatchesShort: 'No matches',
  removeOption: 'Remove {label}',
  listPlaceholder: 'Add an item and press Enter',
  newItem: 'New item',
  itemN: 'Item {n}',
  removeItemN: 'Remove item {n}',
  addressPlaceholder: 'Start typing an address',
  phoneNumber: 'Phone number',
  searchCountry: 'Search country or code',
  searchCountries: 'Search countries',
  countryCode: 'Country code, {name} +{dial}',
  draw: 'Draw',
  type: 'Type',
  sign: 'Sign',
  signatureMethod: 'Signature method',
  typeName: 'Type your full name',
  signAbove: 'Sign above the line',
  expandSignature: 'Expand signature pad',
  expand: 'Expand',
  closeWithoutSaving: 'Close without saving',
  signaturePad: 'Signature pad. Draw with a mouse, finger or pen.',
  typedSignature: 'Typed signature',
  bold: 'Bold',
  italic: 'Italic',
  underline: 'Underline',
  bulletList: 'Bulleted list',
  numberedList: 'Numbered list',
  link: 'Link',
  linkAddress: 'Link address',
  clearFormatting: 'Clear formatting',
  formatting: 'Formatting',
  writeSomething: 'Write something…',
} as const

export type MessageKey = keyof typeof en
export type Messages = Partial<Record<MessageKey | (string & {}), string>>
export type Translate = (key: string, vars?: Record<string, unknown>) => string | undefined
export type T = (key: MessageKey | (string & {}), vars?: Record<string, unknown>) => string

const fill = (template: string, vars: Record<string, unknown> = {}) =>
  template.replace(/\{(\w+)\}/g, (_, k: string) =>
    vars[k] === undefined ? `{${k}}` : String(vars[k]),
  )

export function createT(
  options: { translate?: Translate; messages?: Messages; locale?: string } = {},
): T {
  const plural = new Intl.PluralRules(options.locale ?? 'en')
  const lookup = (key: string, vars?: Record<string, unknown>) =>
    options.translate?.(key, vars) ?? options.messages?.[key] ?? (en as Record<string, string>)[key]
  return (key, vars) => {
    if (vars && typeof vars.count === 'number') {
      const form = plural.select(vars.count)
      const found = lookup(`${key}_${form}`, vars) ?? lookup(`${key}_other`, vars)
      if (found) return fill(found, vars)
    }
    const found = lookup(key, vars)
    return found === undefined ? key : fill(found, vars)
  }
}

export const defaultT = createT()
items.tsx
'use client'

import { useEffect, useRef, useState } from 'react'
import {
  ArrowDown,
  ArrowUp,
  Copy,
  CornerDownLeft,
  CornerUpLeft,
  MoreHorizontal,
  Trash2,
} from 'lucide-react'

import { cn } from '@/lib/utils'

import { toast } from '@/registry/dashboard/components/toast/toast'

import { useFormConfig } from './context'
import { optionRow } from './controls'
import { Popover } from '@/registry/dashboard/components/popover/popover'
import { isNested, type FieldDef, type FieldValue, type Row } from './schema'

export const newId = () => Math.random().toString(36).slice(2, 10)

export function newItem(fields: FieldDef[]): Row {
  const item: Row = { id: newId() }
  for (const f of fields) {
    if (f.compute) continue
    item[f.name] =
      f.type === 'toggle'
        ? false
        : isNested(f) || f.type === 'multiselect' || f.type === 'list' || f.type === 'file'
          ? []
          : ''
  }
  return item
}

export function cloneItem(item: Row): Row {
  const copy = JSON.parse(JSON.stringify(item)) as Row
  const reId = (value: FieldValue | undefined): FieldValue | undefined =>
    Array.isArray(value)
      ? ((value as unknown[]).map((v) =>
          v && typeof v === 'object' && 'id' in (v as object)
            ? Object.fromEntries(
                Object.entries(v as Row).map(([k, inner]) => [
                  k,
                  k === 'id' ? newId() : reId(inner),
                ]),
              )
            : v,
        ) as FieldValue)
      : value
  return Object.fromEntries(
    Object.entries(copy).map(([k, v]) => [k, k === 'id' ? newId() : reId(v)]),
  ) as Row
}

export function move<T>(list: T[], from: number, to: number) {
  if (to < 0 || to >= list.length || from === to) return list
  const next = [...list]
  const [item] = next.splice(from, 1)
  next.splice(to, 0, item)
  return next
}

export type ItemAction = 'above' | 'below' | 'duplicate' | 'up' | 'down' | 'delete'

export function ItemMenu({
  label,
  index,
  count,
  canAdd,
  onAction,
}: {
  label: string
  index: number
  count: number
  canAdd: boolean
  onAction: (action: ItemAction) => void
}) {
  const { t } = useFormConfig()
  const items: {
    action: ItemAction
    text: string
    icon: React.ReactNode
    hidden?: boolean
    danger?: boolean
  }[] = [
    { action: 'above', text: t('insertAbove'), icon: <CornerUpLeft />, hidden: !canAdd },
    { action: 'below', text: t('insertBelow'), icon: <CornerDownLeft />, hidden: !canAdd },
    { action: 'duplicate', text: t('duplicate'), icon: <Copy />, hidden: !canAdd },
    { action: 'up', text: t('moveUp'), icon: <ArrowUp />, hidden: index === 0 },
    { action: 'down', text: t('moveDown'), icon: <ArrowDown />, hidden: index === count - 1 },
    { action: 'delete', text: t('delete'), icon: <Trash2 />, danger: true },
  ]
  return (
    <div className="shrink-0">
      <Popover
        align="end"
        className="w-44"
        trigger={({ open, toggle }) => (
          <button
            type="button"
            aria-label={t('actionsFor', { label })}
            aria-haspopup="menu"
            aria-expanded={open}
            onClick={toggle}
            className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5"
          >
            <MoreHorizontal />
          </button>
        )}
      >
        {(close) => (
          <ul role="menu">
            {items
              .filter((i) => !i.hidden)
              .map((i) => (
                <li key={i.action}>
                  <button
                    type="button"
                    role="menuitem"
                    onClick={() => {
                      close()
                      onAction(i.action)
                    }}
                    className={cn(
                      optionRow,
                      '[&_svg]:size-3.5 [&_svg]:text-muted-foreground',
                      i.danger && 'text-destructive [&_svg]:text-destructive',
                    )}
                  >
                    {i.icon}
                    {i.text}
                  </button>
                </li>
              ))}
          </ul>
        )}
      </Popover>
    </div>
  )
}

export function useUndo() {
  const { t } = useFormConfig()
  const [undo, setUndo] = useState<{ message: string; restore: () => void } | null>(null)
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
  useEffect(() => () => void (timer.current && clearTimeout(timer.current)), [])
  const offer = (message: string, restore: () => void) => {
    if (toast.isMounted()) {
      toast(message, { duration: 6000, action: { label: t('undo'), onClick: restore } })
      return
    }
    if (timer.current) clearTimeout(timer.current)
    setUndo({ message, restore })
    timer.current = setTimeout(() => setUndo(null), 6000)
  }
  const dismiss = () => setUndo(null)
  return { undo, offer, dismiss }
}

export function UndoBar({
  message,
  onUndo,
  onDismiss,
}: {
  message: string
  onUndo: () => void
  onDismiss: () => void
}) {
  const { t } = useFormConfig()
  return (
    <div
      role="status"
      className="flex items-center justify-between gap-3 rounded-lg bg-foreground px-3 py-2 text-sm text-background"
    >
      <span>{message}</span>
      <span className="flex items-center gap-1">
        <button
          type="button"
          onClick={onUndo}
          className="rounded px-2 py-0.5 font-medium hover:bg-background/15"
        >
          {t('undo')}
        </button>
        <button
          type="button"
          aria-label={t('dismiss')}
          onClick={onDismiss}
          className="rounded px-1.5 py-0.5 opacity-70 hover:opacity-100"
        >
          ✕
        </button>
      </span>
    </div>
  )
}

export function applyAction(list: Row[], index: number, action: ItemAction, blank: () => Row) {
  switch (action) {
    case 'above':
      return { list: [...list.slice(0, index), blank(), ...list.slice(index)], focus: index }
    case 'below':
      return {
        list: [...list.slice(0, index + 1), blank(), ...list.slice(index + 1)],
        focus: index + 1,
      }
    case 'duplicate':
      return {
        list: [...list.slice(0, index + 1), cloneItem(list[index]), ...list.slice(index + 1)],
        focus: index + 1,
      }
    case 'up':
      return { list: move(list, index, index - 1), focus: index - 1 }
    case 'down':
      return { list: move(list, index, index + 1), focus: index + 1 }
    case 'delete':
      return { list: list.filter((_, i) => i !== index), focus: Math.max(0, index - 1) }
  }
}
lookup.tsx
'use client'

import { useId, useRef, useState } from 'react'
import { Loader2, MapPin, Search } from 'lucide-react'

import { cn } from '@/lib/utils'

import { Avatar, Check, OptionView, optionRow, triggerClass } from './controls'
import { useFormConfig } from './context'
import { Popover } from '@/registry/dashboard/components/popover/popover'
import { findOption, rememberOptions, type Option, type OptionObject } from './schema'

type Status = 'idle' | 'loading' | 'done' | 'error'

function useSearch(search?: (query: string) => Promise<OptionObject[]>) {
  const [items, setItems] = useState<OptionObject[]>([])
  const [status, setStatus] = useState<Status>('idle')
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
  const latest = useRef(0)

  const run = (query: string, delay = 250) => {
    if (!search) return
    if (timer.current) clearTimeout(timer.current)
    const id = ++latest.current
    setStatus('loading')
    timer.current = setTimeout(() => {
      search(query).then(
        (found) => {
          if (id !== latest.current) return
          rememberOptions(found)
          setItems(found)
          setStatus('done')
        },
        () => id === latest.current && setStatus('error'),
      )
    }, delay)
  }

  return { items, status, run }
}

function Results({
  listId,
  items,
  status,
  query,
  active,
  selected,
  multiple,
  onPick,
  onRetry,
  onHover,
}: {
  listId: string
  items: OptionObject[]
  status: Status
  query: string
  active: number
  selected: string[]
  multiple?: boolean
  onPick: (option: OptionObject) => void
  onRetry: () => void
  onHover: (index: number) => void
}) {
  const { t } = useFormConfig()
  if (status === 'error')
    return (
      <div className="flex items-center justify-between gap-2 px-2 py-2 text-sm text-muted-foreground">
        {t('loadFailed')}
        <button
          type="button"
          onClick={onRetry}
          className="font-medium text-foreground hover:underline"
        >
          {t('tryAgain')}
        </button>
      </div>
    )
  return (
    <ul
      id={listId}
      role="listbox"
      aria-multiselectable={multiple}
      className="max-h-64 overflow-y-auto"
    >
      {status === 'loading' && !items.length && (
        <li className="flex items-center gap-2 px-2 py-2 text-sm text-muted-foreground">
          <Loader2 className="size-3.5 animate-spin" /> {t('searching')}
        </li>
      )}
      {items.map((option, i) => {
        const on = selected.includes(option.value)
        return (
          <li key={option.value}>
            <button
              type="button"
              role="option"
              id={`${listId}-${i}`}
              aria-selected={on}
              tabIndex={-1}
              onMouseEnter={() => onHover(i)}
              onClick={() => onPick(option)}
              className={cn(optionRow, i === active && 'bg-muted')}
            >
              {multiple && <Check on={on} />}
              <OptionView option={option} />
              {!multiple && on && <span aria-hidden>✓</span>}
            </button>
          </li>
        )
      })}
      {status === 'done' && !items.length && (
        <li className="px-2 py-2 text-sm text-muted-foreground">
          {query.trim() ? t('noMatches', { query: query.trim() }) : t('startTyping')}
        </li>
      )}
      {status === 'loading' && items.length > 0 && (
        <li className="flex items-center gap-2 px-2 pt-1 text-xs text-muted-foreground">
          <Loader2 className="size-3 animate-spin" /> {t('updating')}
        </li>
      )}
    </ul>
  )
}

export function LookupField({
  id,
  value,
  multiple = false,
  search,
  options = [],
  placeholder,
  disabled,
  invalid,
  describedBy,
  onChange,
}: {
  id: string
  value: string | string[]
  multiple?: boolean
  search?: (query: string) => Promise<OptionObject[]>
  options?: Option[]
  placeholder?: string
  disabled?: boolean
  invalid?: boolean
  describedBy?: string
  onChange: (value: string | string[]) => void
}) {
  const listId = useId()
  const { t } = useFormConfig()
  const [query, setQuery] = useState('')
  const [active, setActive] = useState(0)
  const { items, status, run } = useSearch(search)
  const selected = Array.isArray(value) ? value : value ? [value] : []
  const label = (v: string) => findOption({ options }, v) ?? { value: v, label: v }

  const pick = (option: OptionObject, close: () => void) => {
    rememberOptions([option])
    if (multiple) {
      onChange(
        selected.includes(option.value)
          ? selected.filter((v) => v !== option.value)
          : [...selected, option.value],
      )
    } else {
      onChange(option.value === value ? '' : option.value)
      close()
    }
  }

  return (
    <Popover
      block
      className="w-full min-w-64"
      trigger={({ open, toggle }) => (
        <div
          id={id}
          role="combobox"
          tabIndex={disabled ? -1 : 0}
          aria-haspopup="listbox"
          aria-expanded={open}
          aria-controls={listId}
          aria-disabled={disabled || undefined}
          aria-describedby={describedBy}
          data-invalid={invalid || undefined}
          onClick={() => {
            if (disabled) return
            if (!open) {
              setQuery('')
              setActive(0)
              run('', 0)
            }
            toggle()
          }}
          onKeyDown={(e) => {
            if (disabled || open || !['Enter', ' ', 'ArrowDown'].includes(e.key)) return
            e.preventDefault()
            setQuery('')
            run('', 0)
            toggle()
          }}
          className={cn(
            triggerClass,
            'cursor-pointer justify-start data-invalid:border-destructive',
            multiple && 'flex-wrap',
          )}
        >
          <Search className="size-3.5 shrink-0 text-muted-foreground" />
          {selected.length === 0 && (
            <span className="truncate text-muted-foreground">{placeholder ?? t('search')}</span>
          )}
          {!multiple && selected[0] && (
            <span className="flex min-w-0 items-center gap-2">
              <Avatar option={label(selected[0])} size="xs" />
              <span className="truncate">{label(selected[0]).label}</span>
            </span>
          )}
          {multiple &&
            selected.map((v) => (
              <span
                key={v}
                className="flex items-center gap-1 rounded-md bg-muted py-0.5 pr-1 pl-1.5 text-xs"
              >
                <Avatar option={label(v)} size="xs" />
                {label(v).label}
                {!disabled && (
                  <button
                    type="button"
                    aria-label={t('removeOption', { label: label(v).label })}
                    onClick={(e) => {
                      e.stopPropagation()
                      onChange(selected.filter((s) => s !== v))
                    }}
                    className="px-0.5 text-muted-foreground hover:text-foreground"
                  >
                    ✕
                  </button>
                )}
              </span>
            ))}
        </div>
      )}
    >
      {(close) => (
        <>
          <input
            autoFocus
            value={query}
            placeholder={t('typeToSearch')}
            aria-label={t('search')}
            aria-controls={listId}
            aria-activedescendant={items[active] ? `${listId}-${active}` : undefined}
            onChange={(e) => {
              setQuery(e.target.value)
              setActive(0)
              run(e.target.value)
            }}
            onKeyDown={(e) => {
              if (e.key === 'ArrowDown') {
                e.preventDefault()
                setActive(Math.min(items.length - 1, active + 1))
              } else if (e.key === 'ArrowUp') {
                e.preventDefault()
                setActive(Math.max(0, active - 1))
              } else if (e.key === 'Enter' && items[active]) {
                e.preventDefault()
                pick(items[active], close)
              }
            }}
            className="mb-1 h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus:border-ring"
          />
          <Results
            listId={listId}
            items={items}
            status={status}
            query={query}
            active={active}
            selected={selected}
            multiple={multiple}
            onPick={(o) => pick(o, close)}
            onRetry={() => run(query, 0)}
            onHover={setActive}
          />
        </>
      )}
    </Popover>
  )
}

export function AddressField({
  id,
  value,
  search,
  placeholder,
  disabled,
  invalid,
  describedBy,
  onChange,
}: {
  id: string
  value: string
  search?: (query: string) => Promise<OptionObject[]>
  placeholder?: string
  disabled?: boolean
  invalid?: boolean
  describedBy?: string
  onChange: (value: string) => void
}) {
  const listId = useId()
  const { t } = useFormConfig()
  const [open, setOpen] = useState(false)
  const [active, setActive] = useState(-1)
  const { items, status, run } = useSearch(search)
  const show = open && value.trim().length >= 3 && status !== 'idle'

  const choose = (option: OptionObject) => {
    onChange(option.label)
    setOpen(false)
  }

  return (
    <div className="relative w-full">
      <div className="flex h-8 w-full min-w-0 items-center gap-2 rounded-lg border border-input bg-card px-2.5 text-sm focus-within:border-ring has-read-only:bg-muted/40 has-aria-invalid:border-destructive">
        <MapPin className="size-3.5 shrink-0 text-muted-foreground" />
        <input
          id={id}
          role="combobox"
          aria-expanded={show}
          aria-controls={listId}
          aria-autocomplete="list"
          aria-activedescendant={show && items[active] ? `${listId}-${active}` : undefined}
          aria-invalid={invalid || undefined}
          aria-describedby={describedBy}
          autoComplete="off"
          readOnly={disabled}
          value={value}
          placeholder={placeholder ?? t('addressPlaceholder')}
          onChange={(e) => {
            onChange(e.target.value)
            setActive(-1)
            setOpen(true)
            if (e.target.value.trim().length >= 3) run(e.target.value)
          }}
          onBlur={() => setTimeout(() => setOpen(false), 120)}
          onKeyDown={(e) => {
            if (!show) return
            if (e.key === 'ArrowDown') {
              e.preventDefault()
              setActive(Math.min(items.length - 1, active + 1))
            } else if (e.key === 'ArrowUp') {
              e.preventDefault()
              setActive(Math.max(0, active - 1))
            } else if (e.key === 'Enter' && items[active]) {
              e.preventDefault()
              choose(items[active])
            } else if (e.key === 'Escape') setOpen(false)
          }}
          className="h-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
        />
        {status === 'loading' && open && (
          <Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
        )}
      </div>
      {show && (
        <div className="absolute top-full left-0 z-50 mt-1.5 w-full min-w-64 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-(--shadow-overlay)">
          <Results
            listId={listId}
            items={items}
            status={status}
            query={value}
            active={active}
            selected={[]}
            onPick={choose}
            onRetry={() => run(value, 0)}
            onHover={setActive}
          />
        </div>
      )}
    </div>
  )
}
number-input.tsx
'use client'

import { useLayoutEffect, useRef, useState } from 'react'

import { cn } from '@/lib/utils'
import { Input } from '@/registry/dashboard/components/input/input'

import { parseDuration, showDuration } from './schema'

const significant = /[\d.-]/

function clean(raw: string, decimals?: number) {
  let out = raw.replace(/[^\d.-]/g, '')
  const negative = out.startsWith('-')
  out = out.replace(/-/g, '')
  const [int, ...rest] = out.split('.')
  let frac = rest.join('')
  if (decimals !== undefined) frac = frac.slice(0, decimals)
  const dot = out.includes('.') && decimals !== 0
  return `${negative ? '-' : ''}${int.replace(/^0+(?=\d)/, '')}${dot ? `.${frac}` : ''}`
}

function display(value: string) {
  if (!value) return ''
  const negative = value.startsWith('-')
  const [int, frac] = value.replace('-', '').split('.')
  const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
  return `${negative ? '-' : ''}${grouped}${frac !== undefined ? `.${frac}` : ''}`
}

export function NumberField({
  id,
  value,
  decimals,
  leading,
  trailing,
  placeholder,
  disabled,
  invalid,
  describedBy,
  onChange,
}: {
  id: string
  value: string
  decimals?: number
  leading?: React.ReactNode
  trailing?: React.ReactNode
  placeholder?: string
  disabled?: boolean
  invalid?: boolean
  describedBy?: string
  onChange: (value: string) => void
}) {
  const input = useRef<HTMLInputElement>(null)
  const [caret, setCaret] = useState<number | null>(null)
  const shown = display(value)

  useLayoutEffect(() => {
    const el = input.current
    if (caret === null || !el || document.activeElement !== el) return
    let seen = 0
    let pos = 0
    while (pos < shown.length && seen < caret) {
      if (significant.test(shown[pos])) seen++
      pos++
    }
    el.setSelectionRange(pos, pos)
  }, [caret, shown])

  return (
    <Input
      ref={input}
      id={id}
      inputMode="decimal"
      value={shown}
      readOnly={disabled}
      placeholder={placeholder}
      aria-invalid={invalid || undefined}
      aria-describedby={describedBy}
      leading={leading}
      trailing={trailing}
      onChange={(e) => {
        const before = e.target.value.slice(0, e.target.selectionStart ?? e.target.value.length)
        setCaret([...before].filter((c) => significant.test(c)).length)
        onChange(clean(e.target.value, decimals))
      }}
      onBlur={() => {
        if (decimals === undefined || !value || value === '-') return
        const n = Number(value)
        if (!Number.isNaN(n)) onChange(n.toFixed(decimals))
      }}
      className={cn('tabular-nums', disabled && 'bg-muted/40')}
    />
  )
}

export function DurationField({
  id,
  value,
  placeholder = 'e.g. 1h 30m',
  disabled,
  invalid,
  describedBy,
  onChange,
}: {
  id: string
  value: string
  placeholder?: string
  disabled?: boolean
  invalid?: boolean
  describedBy?: string
  onChange: (value: string) => void
}) {
  const [draft, setDraft] = useState<string | null>(null)
  const minutes = value === '' ? null : Number(value)
  const pretty = minutes !== null && !Number.isNaN(minutes) ? showDuration(minutes) : value
  const preview = draft !== null ? parseDuration(draft) : null

  const commit = () => {
    if (draft === null) return
    const parsed = parseDuration(draft)
    onChange(parsed === null ? draft.trim() : String(parsed))
    setDraft(null)
  }

  return (
    <Input
      id={id}
      value={draft ?? pretty}
      readOnly={disabled}
      placeholder={placeholder}
      aria-invalid={invalid || undefined}
      aria-describedby={describedBy}
      onFocus={() => setDraft(pretty)}
      onChange={(e) => setDraft(e.target.value)}
      onBlur={commit}
      onKeyDown={(e) => e.key === 'Enter' && commit()}
      trailing={
        draft !== null && draft.trim() ? (
          <span className={cn('text-xs', preview === null && 'text-destructive')}>
            {preview === null ? 'Not a duration' : `= ${showDuration(preview)}`}
          </span>
        ) : undefined
      }
      className={cn(disabled && 'bg-muted/40')}
    />
  )
}
phone.tsx
'use client'

import { useState } from 'react'

import { cn } from '@/lib/utils'

import { useFormConfig } from './context'
import { optionRow } from './controls'
import { Popover } from '@/registry/dashboard/components/popover/popover'

export type Country = { code: string; name: string; dial: string }

export const countries: Country[] = [
  { code: 'ET', name: 'Ethiopia', dial: '251' },
  { code: 'US', name: 'United States', dial: '1' },
  { code: 'CA', name: 'Canada', dial: '1' },
  { code: 'GB', name: 'United Kingdom', dial: '44' },
  { code: 'DE', name: 'Germany', dial: '49' },
  { code: 'FR', name: 'France', dial: '33' },
  { code: 'IT', name: 'Italy', dial: '39' },
  { code: 'ES', name: 'Spain', dial: '34' },
  { code: 'NL', name: 'Netherlands', dial: '31' },
  { code: 'SE', name: 'Sweden', dial: '46' },
  { code: 'CH', name: 'Switzerland', dial: '41' },
  { code: 'TR', name: 'Turkey', dial: '90' },
  { code: 'AE', name: 'United Arab Emirates', dial: '971' },
  { code: 'SA', name: 'Saudi Arabia', dial: '966' },
  { code: 'EG', name: 'Egypt', dial: '20' },
  { code: 'KE', name: 'Kenya', dial: '254' },
  { code: 'UG', name: 'Uganda', dial: '256' },
  { code: 'TZ', name: 'Tanzania', dial: '255' },
  { code: 'RW', name: 'Rwanda', dial: '250' },
  { code: 'DJ', name: 'Djibouti', dial: '253' },
  { code: 'SO', name: 'Somalia', dial: '252' },
  { code: 'SD', name: 'Sudan', dial: '249' },
  { code: 'NG', name: 'Nigeria', dial: '234' },
  { code: 'GH', name: 'Ghana', dial: '233' },
  { code: 'ZA', name: 'South Africa', dial: '27' },
  { code: 'IN', name: 'India', dial: '91' },
  { code: 'CN', name: 'China', dial: '86' },
  { code: 'JP', name: 'Japan', dial: '81' },
  { code: 'KR', name: 'South Korea', dial: '82' },
  { code: 'SG', name: 'Singapore', dial: '65' },
  { code: 'AU', name: 'Australia', dial: '61' },
  { code: 'BR', name: 'Brazil', dial: '55' },
  { code: 'MX', name: 'Mexico', dial: '52' },
]

const flag = (code: string) =>
  String.fromCodePoint(...[...code.toUpperCase()].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65))

const group = (digits: string) => digits.replace(/(\d{3})(?=\d)/g, '$1 ').trim()

export function splitPhone(value: string, fallback: string) {
  const match = /^\+(\d+)\s*(.*)$/.exec(value.trim())
  if (!match)
    return {
      country: countries.find((c) => c.code === fallback) ?? countries[0],
      national: value.replace(/\D/g, ''),
    }
  const digits = match[1] + match[2].replace(/\D/g, '')
  const preferred = countries.find((c) => c.code === fallback)
  const country =
    (preferred && digits.startsWith(preferred.dial) ? preferred : undefined) ??
    [...countries]
      .sort((a, b) => b.dial.length - a.dial.length)
      .find((c) => digits.startsWith(c.dial)) ??
    countries[0]
  return { country, national: digits.slice(country.dial.length) }
}

export function PhoneField({
  id,
  value,
  defaultCountry = 'US',
  placeholder,
  disabled,
  invalid,
  describedBy,
  onChange,
}: {
  id: string
  value: string
  defaultCountry?: string
  placeholder?: string
  disabled?: boolean
  invalid?: boolean
  describedBy?: string
  onChange: (value: string) => void
}) {
  const { t } = useFormConfig()
  const parsed = splitPhone(value, defaultCountry)
  const [chosen, setChosen] = useState<Country | null>(null)
  const country = chosen ?? parsed.country
  const [query, setQuery] = useState('')
  const shown = countries.filter(
    (c) =>
      c.name.toLowerCase().includes(query.toLowerCase()) ||
      c.dial.startsWith(query.replace(/\D/g, '') || '§'),
  )
  const emit = (c: Country, national: string) =>
    onChange(national ? `+${c.dial} ${group(national)}` : '')

  return (
    <div
      className={cn(
        'flex h-8 w-full min-w-0 items-center rounded-lg border border-input bg-card text-sm focus-within:border-ring',
        invalid && 'border-destructive',
        disabled && 'bg-muted/40',
      )}
    >
      <div className="shrink-0">
        <Popover
          className="w-64"
          trigger={({ open, toggle }) => (
            <button
              type="button"
              aria-label={t('countryCode', { name: country.name, dial: country.dial })}
              aria-haspopup="listbox"
              aria-expanded={open}
              disabled={disabled}
              onClick={toggle}
              className="flex h-[30px] items-center gap-1.5 rounded-l-lg border-r border-input px-2 tabular-nums hover:bg-muted disabled:hover:bg-transparent"
            >
              <span aria-hidden>{flag(country.code)}</span>
              <span className="text-muted-foreground">+{country.dial}</span>
            </button>
          )}
        >
          {(close) => (
            <>
              <input
                autoFocus
                value={query}
                placeholder={t('searchCountry')}
                aria-label={t('searchCountries')}
                onChange={(e) => setQuery(e.target.value)}
                className="mb-1 h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus:border-ring"
              />
              <ul role="listbox" className="max-h-64 overflow-y-auto">
                {shown.map((c) => (
                  <li key={c.code}>
                    <button
                      type="button"
                      role="option"
                      aria-selected={c.code === country.code}
                      onClick={() => {
                        setChosen(c)
                        emit(c, parsed.national)
                        setQuery('')
                        close()
                      }}
                      className={optionRow}
                    >
                      <span aria-hidden>{flag(c.code)}</span>
                      <span className="flex-1 truncate">{c.name}</span>
                      <span className="text-muted-foreground tabular-nums">+{c.dial}</span>
                    </button>
                  </li>
                ))}
              </ul>
            </>
          )}
        </Popover>
      </div>
      <input
        id={id}
        type="tel"
        inputMode="tel"
        autoComplete="tel-national"
        readOnly={disabled}
        aria-invalid={invalid || undefined}
        aria-describedby={describedBy}
        value={group(parsed.national)}
        placeholder={placeholder ?? t('phoneNumber')}
        onChange={(e) => {
          const raw = e.target.value.trim()
          if (raw.startsWith('+')) {
            const next = splitPhone(raw, country.code)
            setChosen(next.country)
            return emit(next.country, next.national)
          }
          emit(country, raw.replace(/\D/g, '').slice(0, 15))
        }}
        className="h-full min-w-0 flex-1 bg-transparent px-2.5 tabular-nums outline-none placeholder:text-muted-foreground"
      />
    </div>
  )
}
resolver.ts
import type { FieldErrors, Resolver } from 'react-hook-form'

import { createT, type Messages, type Translate } from './i18n'
import {
  applyComputed,
  validateRecord,
  type FieldTypeMap,
  type FormErrors,
  type FormSection,
  type FormValues,
} from './schema'

type PathSegment = PropertyKey | { readonly key: PropertyKey }

type StandardIssue = { readonly message: string; readonly path?: ReadonlyArray<PathSegment> }

type StandardResult =
  | { readonly value: unknown; readonly issues?: undefined }
  | { readonly issues: ReadonlyArray<StandardIssue> }

export type StandardSchema = {
  readonly '~standard': {
    readonly version: 1
    readonly vendor: string
    readonly validate: (value: unknown) => StandardResult | Promise<StandardResult>
  }
}

export const FORM_ERROR = '_form'

export type RecordFormResolverOptions = {
  sections: FormSection[]
  schema?: StandardSchema
  fieldTypes?: FieldTypeMap
  roles?: string[]
  locale?: string
  messages?: Messages
  translate?: Translate
}

function issuesToErrors(issues: ReadonlyArray<StandardIssue>): FormErrors {
  const errors: FormErrors = {}
  for (const issue of issues) {
    const key =
      issue.path
        ?.map((p) => String(typeof p === 'object' && p !== null && 'key' in p ? p.key : p))
        .join('.') || FORM_ERROR
    errors[key] ??= issue.message
  }
  return errors
}

function nestErrors(flat: FormErrors): FieldErrors<FormValues> {
  const out: Record<string, unknown> = {}
  for (const [path, message] of Object.entries(flat)) {
    const keys = path.split('.')
    let node = out
    keys.forEach((key, i) => {
      if (i === keys.length - 1) node[key] = { ...(node[key] as object), type: 'validate', message }
      else node = (node[key] ??= {}) as Record<string, unknown>
    })
  }
  return out as FieldErrors<FormValues>
}

export function flattenErrors(errors: unknown, prefix = '', out: FormErrors = {}): FormErrors {
  if (!errors || typeof errors !== 'object') return out
  for (const [key, node] of Object.entries(errors as Record<string, unknown>)) {
    if (key === 'ref' || key === 'types' || !node || typeof node !== 'object') continue
    const path = prefix ? `${prefix}.${key}` : key
    const message = (node as { message?: unknown }).message
    if (typeof message === 'string' && message) out[path] = message
    flattenErrors(node, path, out)
  }
  return out
}

export function recordFormResolver({
  sections,
  schema,
  fieldTypes,
  roles,
  locale,
  messages,
  translate,
}: RecordFormResolverOptions): Resolver<FormValues> {
  const t = createT({ messages, translate, locale })
  return async (values) => {
    const computed = applyComputed(sections, values)
    const result = schema ? await schema['~standard'].validate(computed) : undefined
    const errors = {
      ...validateRecord(sections, computed, fieldTypes, { t, locale, roles }),
      ...(result?.issues ? issuesToErrors(result.issues) : {}),
    }
    return Object.keys(errors).length
      ? { values: {}, errors: nestErrors(errors) }
      : { values: computed, errors: {} }
  }
}
rich-text.tsx
'use client'

import { useEffect, useRef, useState } from 'react'
import { Bold, Italic, Link2, List, ListOrdered, RemoveFormatting, Underline } from 'lucide-react'

import { cn } from '@/lib/utils'

import { useFormConfig } from './context'
import { plainText } from './schema'

const ALLOWED = new Set(['B', 'STRONG', 'I', 'EM', 'U', 'P', 'BR', 'UL', 'OL', 'LI', 'A', 'DIV'])
const DROP = new Set(['SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'TEMPLATE', 'NOSCRIPT'])
const SAFE_URL = /^(https?:|mailto:)/i

export function sanitizeHtml(html: string) {
  if (typeof window === 'undefined') return ''
  const doc = new DOMParser().parseFromString(`<div>${html}</div>`, 'text/html')
  const root = doc.body.firstElementChild as HTMLElement
  const clean = (node: Element) => {
    for (const child of [...node.children]) {
      if (DROP.has(child.tagName)) {
        child.remove()
        continue
      }
      clean(child)
      if (!ALLOWED.has(child.tagName)) {
        child.replaceWith(...child.childNodes)
        continue
      }
      const href = child.getAttribute('href')
      for (const attr of [...child.attributes]) child.removeAttribute(attr.name)
      if (child.tagName === 'A') {
        if (href && SAFE_URL.test(href.trim())) {
          child.setAttribute('href', href.trim())
          child.setAttribute('target', '_blank')
          child.setAttribute('rel', 'noopener noreferrer')
        } else child.replaceWith(...child.childNodes)
      }
    }
  }
  clean(root)
  return root.innerHTML
}

const tools = [
  { command: 'bold', label: 'bold', icon: <Bold />, keys: 'Ctrl+B' },
  { command: 'italic', label: 'italic', icon: <Italic />, keys: 'Ctrl+I' },
  { command: 'underline', label: 'underline', icon: <Underline />, keys: 'Ctrl+U' },
  { command: 'insertUnorderedList', label: 'bulletList', icon: <List /> },
  { command: 'insertOrderedList', label: 'numberedList', icon: <ListOrdered /> },
]

const prose =
  '[&_a]:underline [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5 [&_p]:my-1'

export function RichTextField({
  id,
  value,
  placeholder,
  disabled,
  invalid,
  describedBy,
  onChange,
}: {
  id: string
  value: string
  placeholder?: string
  disabled?: boolean
  invalid?: boolean
  describedBy?: string
  onChange: (value: string) => void
}) {
  const { t } = useFormConfig()
  const editor = useRef<HTMLDivElement>(null)
  const emitted = useRef<string | null>(null)
  const saved = useRef<Range | null>(null)
  const [active, setActive] = useState<Record<string, boolean>>({})
  const [linking, setLinking] = useState(false)
  const [url, setUrl] = useState('')
  const empty = !plainText(value)

  useEffect(() => {
    const el = editor.current
    if (!el || (value === emitted.current && el.dataset.filled)) return
    el.innerHTML = sanitizeHtml(value)
    el.dataset.filled = '1'
    emitted.current = value
  }, [value, disabled])

  useEffect(() => {
    if (disabled) return
    const update = () => {
      if (!editor.current?.contains(document.getSelection()?.anchorNode ?? null)) return
      setActive(
        Object.fromEntries(
          tools.map((tool) => [tool.command, document.queryCommandState(tool.command)]),
        ),
      )
    }
    document.addEventListener('selectionchange', update)
    return () => document.removeEventListener('selectionchange', update)
  }, [disabled])

  const emit = () => {
    const html = sanitizeHtml(editor.current?.innerHTML ?? '')
    emitted.current = html
    onChange(plainText(html) || /<img/i.test(html) ? html : '')
  }

  const run = (command: string, arg?: string) => {
    editor.current?.focus()
    document.execCommand(command, false, arg)
    emit()
  }

  const startLink = () => {
    const selection = document.getSelection()
    saved.current = selection?.rangeCount ? selection.getRangeAt(0).cloneRange() : null
    setUrl('https://')
    setLinking(true)
  }

  const applyLink = () => {
    const href = url.trim()
    setLinking(false)
    if (!SAFE_URL.test(href) || !saved.current) return
    editor.current?.focus()
    const selection = document.getSelection()
    selection?.removeAllRanges()
    selection?.addRange(saved.current)
    run('createLink', href)
  }

  if (disabled) {
    return (
      <div
        ref={editor}
        id={id}
        aria-describedby={describedBy}
        className={cn(
          'min-h-8 rounded-lg bg-muted/40 px-2.5 py-1.5 text-sm leading-relaxed',
          prose,
        )}
      />
    )
  }

  return (
    <div
      className={cn(
        'rounded-lg border border-input bg-card transition-[border-color] focus-within:border-ring',
        invalid && 'border-destructive',
      )}
    >
      <div
        role="toolbar"
        aria-label={t('formatting')}
        className="flex flex-wrap items-center gap-0.5 border-b p-1"
      >
        {tools.map((tool) => (
          <button
            key={tool.command}
            type="button"
            title={tool.keys ? `${t(tool.label)} (${tool.keys})` : t(tool.label)}
            aria-label={t(tool.label)}
            aria-pressed={!!active[tool.command]}
            onMouseDown={(e) => e.preventDefault()}
            onClick={() => run(tool.command)}
            className={cn(
              'flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5',
              active[tool.command] && 'bg-muted text-foreground',
            )}
          >
            {tool.icon}
          </button>
        ))}
        <span aria-hidden className="mx-1 h-4 w-px bg-border" />
        <button
          type="button"
          title={t('link')}
          aria-label={t('link')}
          onMouseDown={(e) => e.preventDefault()}
          onClick={startLink}
          className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5"
        >
          <Link2 />
        </button>
        <button
          type="button"
          title={t('clearFormatting')}
          aria-label={t('clearFormatting')}
          onMouseDown={(e) => e.preventDefault()}
          onClick={() => run('removeFormat')}
          className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5"
        >
          <RemoveFormatting />
        </button>
        {linking && (
          <div className="flex min-w-0 flex-1 items-center gap-1">
            <input
              autoFocus
              value={url}
              aria-label={t('linkAddress')}
              onChange={(e) => setUrl(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === 'Enter') {
                  e.preventDefault()
                  applyLink()
                }
                if (e.key === 'Escape') {
                  e.stopPropagation()
                  setLinking(false)
                }
              }}
              className="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:border-ring"
            />
            <button
              type="button"
              onClick={applyLink}
              className="h-7 rounded-md bg-primary px-2 text-xs font-medium text-primary-foreground"
            >
              {t('add')}
            </button>
          </div>
        )}
      </div>
      <div className="relative">
        {empty && (
          <span
            aria-hidden
            className="pointer-events-none absolute top-2 left-2.5 text-sm text-muted-foreground"
          >
            {placeholder ?? t('writeSomething')}
          </span>
        )}
        <div
          ref={editor}
          id={id}
          role="textbox"
          aria-multiline
          aria-invalid={invalid || undefined}
          aria-describedby={describedBy}
          contentEditable
          suppressContentEditableWarning
          onInput={emit}
          onPaste={(e) => {
            e.preventDefault()
            document.execCommand('insertText', false, e.clipboardData.getData('text/plain'))
          }}
          className={cn('min-h-24 px-2.5 py-2 text-sm leading-relaxed outline-none', prose)}
        />
      </div>
    </div>
  )
}
rows.tsx
'use client'

import { Fragment, useEffect, useRef, useState } from 'react'
import { ChevronRight, Copy, GripVertical, Plus, Trash2 } from 'lucide-react'

import { cn } from '@/lib/utils'
import { parseDate } from '@/registry/dashboard/components/date-picker/date-picker'
import { toInputValue } from '@/registry/dashboard/components/date-range-picker/dates'
import { Field } from '@/registry/dashboard/components/input/input'
import { toast } from '@/registry/dashboard/components/toast/toast'

import { Check } from './controls'
import { useFormConfig } from './context'
import type { FieldControlProps } from './field-control'
import {
  applyAction,
  cloneItem,
  ItemMenu,
  move,
  newItem,
  UndoBar,
  useUndo,
  type ItemAction,
} from './items'
import {
  isReadOnly,
  isRequired,
  isVisible,
  normalizeOptions,
  scope,
  type FieldDef,
  type FieldValue,
  type Row,
} from './schema'

export type NestedProps = FieldControlProps & {
  Control: React.ComponentType<FieldControlProps>
}

const cellWidth: Record<string, string> = {
  date: 'min-w-40',
  datetime: 'min-w-52',
  daterange: 'min-w-56',
  time: 'min-w-32',
  month: 'min-w-36',
  select: 'min-w-32',
  multiselect: 'min-w-40',
  lookup: 'min-w-44',
  phone: 'min-w-48',
  number: 'min-w-20',
  currency: 'min-w-28',
  percent: 'min-w-24',
  duration: 'min-w-28',
  toggle: 'min-w-14',
}

const PASTEABLE = new Set([
  'text',
  'email',
  'url',
  'phone',
  'textarea',
  'number',
  'currency',
  'percent',
  'select',
  'radio',
  'date',
  'toggle',
])

function fromPaste(col: FieldDef, raw: string): FieldValue | undefined {
  const t = raw.trim()
  switch (col.type) {
    case 'number':
    case 'currency':
    case 'percent':
      return t.replace(/[^\d.-]/g, '')
    case 'toggle':
      return /^(y|yes|true|1|x|✓|✔)$/i.test(t)
    case 'select':
    case 'radio': {
      const match = normalizeOptions(col.options).find(
        (o) =>
          o.value.toLowerCase() === t.toLowerCase() || o.label.toLowerCase() === t.toLowerCase(),
      )
      return match ? match.value : undefined
    }
    case 'date': {
      const d = parseDate(t)
      return d ? toInputValue(d) : undefined
    }
    default:
      return t
  }
}

export const hasErrorUnder = (errors: Record<string, string>, prefix: string) =>
  Object.keys(errors).some((k) => errors[k] && k.startsWith(`${prefix}.`))

export function RowsField({ Control, ...props }: NestedProps) {
  const {
    id,
    field,
    value,
    values,
    disabled,
    describedBy,
    onChange,
    currency = '$',
    errors = {},
    fieldTypes,
    path = field.name,
  } = props
  const { t, roles } = useFormConfig()
  const columns = field.columns ?? []
  const details = field.details ?? []
  const all = [...columns, ...details]
  const rows = Array.isArray(value) ? (value as Row[]) : []
  const canAdd = !disabled && (field.maxLength === undefined || rows.length < field.maxLength)

  const [selected, setSelected] = useState<string[]>([])
  const [open, setOpen] = useState<string[]>([])
  const [compact, setCompact] = useState(false)
  const [width, setWidth] = useState(0)
  const [drag, setDrag] = useState<{ from: number; over: number } | null>(null)
  const [armed, setArmed] = useState<string | null>(null)
  const [note, setNote] = useState('')
  const wrap = useRef<HTMLDivElement>(null)
  const pendingFocus = useRef<number | null>(null)
  const noteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
  const { undo, offer, dismiss } = useUndo()

  useEffect(() => {
    const el = wrap.current
    if (!el) return
    const observer = new ResizeObserver(([entry]) => {
      setCompact(entry.contentRect.width < 560)
      setWidth(entry.contentRect.width)
    })
    observer.observe(el)
    return () => observer.disconnect()
  }, [])

  useEffect(() => {
    const index = pendingFocus.current
    if (index === null) return
    pendingFocus.current = null
    wrap.current
      ?.querySelector<HTMLElement>(
        `[data-row="${index}"] [data-cell] :is(input:not([type=file]), textarea, button, [role=combobox])`,
      )
      ?.focus()
  }, [value])

  const ctxOf = (row: Row) => scope(values, row)
  const cellValue = (row: Row, col: FieldDef) =>
    col.compute ? col.compute(ctxOf(row)) : row[col.name]
  const blank = () => newItem(all)
  const setCell = (i: number, name: string, v: FieldValue) =>
    onChange(rows.map((r, j) => (j === i ? { ...r, [name]: v } : r)))

  const flash = (text: string) => {
    if (toast.isMounted()) return void toast.success(text)
    setNote(text)
    if (noteTimer.current) clearTimeout(noteTimer.current)
    noteTimer.current = setTimeout(() => setNote(''), 4000)
  }

  const remove = (ids: string[]) => {
    const before = rows
    onChange(rows.filter((r) => !ids.includes(r.id)))
    setSelected((s) => s.filter((x) => !ids.includes(x)))
    offer(
      ids.length === 1
        ? t('rowDeleted', { n: before.findIndex((r) => r.id === ids[0]) + 1 })
        : t('rowsDeleted', { count: ids.length }),
      () => onChange(before),
    )
  }

  const act = (i: number, action: ItemAction) => {
    if (action === 'delete') return remove([rows[i].id])
    const result = applyAction(rows, i, action, blank)
    pendingFocus.current = result.focus
    onChange(result.list)
  }

  const add = () => {
    pendingFocus.current = rows.length
    onChange([...rows, blank()])
  }

  const duplicateSelected = () => {
    const next: Row[] = []
    for (const r of rows) {
      next.push(r)
      if (selected.includes(r.id)) next.push(cloneItem(r))
    }
    onChange(field.maxLength !== undefined ? next.slice(0, field.maxLength) : next)
    setSelected([])
  }

  const paste = (e: React.ClipboardEvent) => {
    if (disabled) return
    const cell = (e.target as HTMLElement).closest<HTMLElement>('[data-cell]')
    if (!cell || cell.closest('[data-nested]') !== e.currentTarget.closest('[data-nested]')) return
    const text = e.clipboardData.getData('text/plain')
    if (!/[\t\n]/.test(text.trim())) return
    e.preventDefault()
    const grid = text
      .replace(/\r/g, '')
      .replace(/\n+$/, '')
      .split('\n')
      .map((line) => line.split('\t'))
    const r0 = Number(cell.dataset.row)
    const c0 = Number(cell.dataset.col)
    const next = [...rows]
    let added = 0
    let filled = 0
    grid.forEach((cells, dr) => {
      const ri = r0 + dr
      if (ri >= next.length) {
        if (field.maxLength !== undefined && next.length >= field.maxLength) return
        next.push(blank())
        added++
      }
      const row = { ...next[ri] }
      cells.forEach((raw, dc) => {
        const col = columns[c0 + dc]
        if (!col || col.compute || col.readOnly || !PASTEABLE.has(col.type)) return
        const v = fromPaste(col, raw)
        if (v !== undefined) {
          row[col.name] = v
          filled++
        }
      })
      next[ri] = row
    })
    onChange(next)
    flash(
      `${t('pasted', { count: filled })}${added ? ` · ${t('rowsAdded', { count: added })}` : ''}`,
    )
  }

  const rowKeys = (i: number) => (e: React.KeyboardEvent) => {
    const target = e.target as HTMLElement
    if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
      e.preventDefault()
      e.stopPropagation()
      act(i, e.key === 'ArrowUp' ? 'up' : 'down')
    } else if (
      e.key === 'Enter' &&
      !e.shiftKey &&
      target.tagName === 'INPUT' &&
      target.id.startsWith('field-') &&
      i === rows.length - 1 &&
      canAdd
    ) {
      e.preventDefault()
      e.stopPropagation()
      add()
    }
  }

  const control = (row: Row, i: number, col: FieldDef, j: number, label: boolean) => {
    const ctx = ctxOf(row)
    const key = `${path}.${i}.${col.name}`
    const error = errors[key]
    if (!isVisible(col, ctx, roles))
      return label ? null : <span className="px-2 text-muted-foreground">—</span>
    const node = (
      <Control
        id={`field-${key}`}
        path={key}
        field={label ? col : { ...col, label: `${col.label}, row ${i + 1}` }}
        value={cellValue(row, col)}
        values={ctx}
        invalid={!!error}
        disabled={!!disabled || isReadOnly(col, ctx, roles)}
        describedBy={error ? `field-${key}-error` : undefined}
        currency={currency}
        errors={errors}
        fieldTypes={fieldTypes}
        onChange={(v) => setCell(i, col.name, v)}
      />
    )
    if (label)
      return (
        <div
          key={col.name}
          data-cell
          data-row={i}
          data-col={j}
          className={cn(
            'min-w-0',
            (col.type === 'rows' ||
              col.type === 'group' ||
              col.type === 'textarea' ||
              col.type === 'richtext') &&
              'col-span-full',
          )}
        >
          <Field
            label={col.label}
            htmlFor={`field-${key}`}
            hint={col.hint ?? (col.compute ? t('calculated') : undefined)}
            error={error}
            required={!disabled && isRequired(col, ctx)}
          >
            {node}
          </Field>
        </div>
      )
    return (
      <>
        {node}
        {error && (
          <span id={`field-${key}-error`} className="mt-1 block text-xs text-destructive">
            {error}
          </span>
        )}
      </>
    )
  }

  const totals =
    field.totals?.length && rows.length
      ? field.totals.map((name) => {
          const col = columns.find((c) => c.name === name)
          const sum = rows.reduce((s, r) => s + (Number(col ? cellValue(r, col) : r[name]) || 0), 0)
          const text = new Intl.NumberFormat('en-US', {
            maximumFractionDigits: col?.decimals ?? 2,
          }).format(sum)
          return {
            name,
            label: col?.label ?? name,
            text: `${col?.type === 'currency' ? currency : ''}${text}${col?.type === 'percent' ? '%' : ''}`,
          }
        })
      : null

  const allSelected = rows.length > 0 && selected.length === rows.length
  const toggleAll = () => setSelected(allSelected ? [] : rows.map((r) => r.id))
  const toggleOne = (rowId: string) =>
    setSelected((s) => (s.includes(rowId) ? s.filter((x) => x !== rowId) : [...s, rowId]))
  const detailHasError = (i: number) =>
    details.some((d) =>
      Object.keys(errors).some(
        (k) =>
          errors[k] && (k === `${path}.${i}.${d.name}` || k.startsWith(`${path}.${i}.${d.name}.`)),
      ),
    )
  const isOpen = (row: Row, i: number) => open.includes(row.id) || detailHasError(i)
  const toggleOpen = (rowId: string) =>
    setOpen((o) => (o.includes(rowId) ? o.filter((x) => x !== rowId) : [...o, rowId]))

  const checkbox = (on: boolean, label: string, onToggle: () => void) => (
    <button
      type="button"
      role="checkbox"
      aria-checked={on}
      aria-label={label}
      onClick={onToggle}
      className="flex size-6 items-center justify-center rounded"
    >
      <Check on={on} />
    </button>
  )

  const span = (disabled ? 0 : 2) + 1 + (details.length ? 1 : 0) + columns.length
  const leads = { check: 0, number: disabled ? 0 : 2, details: (disabled ? 0 : 2) + 2.25 }
  const firstLeft = leads.details + (details.length ? 1.75 : 0)
  const pinned = (part: 'head' | 'body' | 'foot', left: number, edge = false) => ({
    style: { left: `${left}rem` },
    className: cn(
      'sticky z-10',
      part === 'head' && 'bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))]',
      part === 'foot' && 'bg-[color-mix(in_oklab,var(--muted)_30%,var(--card))]',
      part === 'body' && 'bg-card group-data-[selected=true]:bg-muted',
      edge && 'shadow-[1px_0_0_var(--border)]',
    ),
  })

  return (
    <div
      ref={wrap}
      id={id}
      data-nested
      aria-describedby={describedBy}
      className="flex min-w-0 flex-col gap-2"
      onPaste={paste}
    >
      {selected.length > 0 && (
        <div className="flex items-center gap-2 rounded-lg bg-muted px-2 py-1.5 text-sm">
          <span className="px-1 font-medium tabular-nums">
            {t('selected', { count: selected.length })}
          </span>
          {canAdd && (
            <button
              type="button"
              onClick={duplicateSelected}
              className="flex h-7 items-center gap-1.5 rounded-md px-2 hover:bg-card [&_svg]:size-3.5"
            >
              <Copy /> {t('duplicate')}
            </button>
          )}
          <button
            type="button"
            onClick={() => remove(selected)}
            className="flex h-7 items-center gap-1.5 rounded-md px-2 text-destructive hover:bg-card [&_svg]:size-3.5"
          >
            <Trash2 /> {t('delete')}
          </button>
          <button
            type="button"
            onClick={() => setSelected([])}
            className="ml-auto h-7 rounded-md px-2 text-muted-foreground hover:bg-card hover:text-foreground"
          >
            {t('clear')}
          </button>
        </div>
      )}

      {compact ? (
        <ul className="flex flex-col gap-2">
          {rows.map((row, i) => (
            <li
              key={row.id}
              data-row={i}
              onKeyDown={rowKeys(i)}
              className={cn(
                'rounded-lg border bg-card p-3',
                selected.includes(row.id) && 'border-ring',
              )}
            >
              <div className="mb-3 flex items-center gap-2">
                {!disabled &&
                  checkbox(selected.includes(row.id), t('selectRow', { n: i + 1 }), () =>
                    toggleOne(row.id),
                  )}
                <span className="flex-1 text-sm font-medium">{t('rowLabel', { n: i + 1 })}</span>
                {!disabled && (
                  <ItemMenu
                    label={t('rowLabel', { n: i + 1 })}
                    index={i}
                    count={rows.length}
                    canAdd={canAdd}
                    onAction={(a) => act(i, a)}
                  />
                )}
              </div>
              <div className="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-3">
                {all.map((col) => control(row, i, col, columns.indexOf(col), true))}
              </div>
            </li>
          ))}
          {!rows.length && (
            <li className="rounded-lg border border-dashed px-3 py-5 text-center text-sm text-muted-foreground">
              {t('noRows')}
            </li>
          )}
          {totals && (
            <li className="flex flex-wrap justify-end gap-x-4 gap-y-1 px-1 text-sm">
              {totals.map((t) => (
                <span key={t.name}>
                  <span className="text-muted-foreground">Total {t.label.toLowerCase()}: </span>
                  <span className="font-medium tabular-nums">{t.text}</span>
                </span>
              ))}
            </li>
          )}
        </ul>
      ) : (
        <div className="overflow-x-auto rounded-lg border">
          <table className="w-full border-collapse text-sm">
            <thead>
              <tr className="border-b bg-muted/50 text-left text-xs text-muted-foreground">
                {!disabled && (
                  <th
                    style={pinned('head', leads.check).style}
                    className={cn(pinned('head', leads.check).className, 'w-8 min-w-8 pl-1.5')}
                  >
                    {checkbox(allSelected, t('selectAll'), toggleAll)}
                  </th>
                )}
                <th
                  style={pinned('head', leads.number).style}
                  className={cn(
                    pinned('head', leads.number).className,
                    'w-9 min-w-9 px-1 text-center font-medium',
                  )}
                >
                  #
                </th>
                {details.length > 0 && (
                  <th
                    aria-label="Details"
                    style={pinned('head', leads.details).style}
                    className={cn(pinned('head', leads.details).className, 'w-7 min-w-7')}
                  />
                )}
                {columns.map((c, j) => (
                  <th
                    key={c.name}
                    scope="col"
                    style={j === 0 ? pinned('head', firstLeft).style : undefined}
                    className={cn(
                      'px-2 py-1.5 font-medium whitespace-nowrap',
                      j === 0 && pinned('head', firstLeft, true).className,
                    )}
                  >
                    {c.label}
                    {c.required && <span aria-hidden> *</span>}
                  </th>
                ))}
                {!disabled && <th className="w-9" aria-label="Actions" />}
              </tr>
            </thead>
            <tbody>
              {rows.map((row, i) => {
                const expanded = details.length > 0 && isOpen(row, i)
                const detailError = detailHasError(i)
                const dropping = drag && drag.over === i && drag.from !== i
                return (
                  <Fragment key={row.id}>
                    <tr
                      data-row={i}
                      draggable={armed === row.id}
                      onKeyDown={rowKeys(i)}
                      onDragStart={(e) => {
                        e.dataTransfer.effectAllowed = 'move'
                        e.dataTransfer.setData('text/plain', row.id)
                        setDrag({ from: i, over: i })
                      }}
                      onDragOver={(e) => {
                        if (!drag) return
                        e.preventDefault()
                        if (drag.over !== i) setDrag({ ...drag, over: i })
                      }}
                      onDrop={(e) => {
                        e.preventDefault()
                        if (drag) onChange(move(rows, drag.from, drag.over))
                        setDrag(null)
                        setArmed(null)
                      }}
                      onDragEnd={() => {
                        setDrag(null)
                        setArmed(null)
                      }}
                      data-selected={selected.includes(row.id) || undefined}
                      className={cn(
                        'group border-b last:border-0',
                        selected.includes(row.id) && 'bg-muted',
                        drag?.from === i && 'opacity-50',
                        dropping &&
                          (drag!.over < drag!.from
                            ? 'shadow-[inset_0_2px_0_var(--primary)]'
                            : 'shadow-[inset_0_-2px_0_var(--primary)]'),
                      )}
                    >
                      {!disabled && (
                        <td
                          style={pinned('body', leads.check).style}
                          className={cn(
                            pinned('body', leads.check).className,
                            'w-8 min-w-8 pl-1.5 align-top',
                          )}
                        >
                          <div className="flex h-11 items-center">
                            {checkbox(selected.includes(row.id), t('selectRow', { n: i + 1 }), () =>
                              toggleOne(row.id),
                            )}
                          </div>
                        </td>
                      )}
                      <td
                        style={pinned('body', leads.number).style}
                        className={cn(
                          pinned('body', leads.number).className,
                          'w-9 min-w-9 px-1 align-top',
                        )}
                      >
                        <div className="flex h-11 items-center justify-center text-xs text-muted-foreground tabular-nums">
                          {!disabled ? (
                            <>
                              <span className="group-focus-within:hidden group-hover:hidden">
                                {i + 1}
                              </span>
                              <button
                                type="button"
                                aria-label={t('dragRow', { n: i + 1 })}
                                title={t('dragRow', { n: i + 1 })}
                                onPointerDown={() => setArmed(row.id)}
                                onPointerUp={() => !drag && setArmed(null)}
                                className="hidden cursor-grab rounded p-0.5 group-focus-within:flex group-hover:flex hover:bg-muted active:cursor-grabbing [&_svg]:size-3.5"
                              >
                                <GripVertical />
                              </button>
                            </>
                          ) : (
                            i + 1
                          )}
                        </div>
                      </td>
                      {details.length > 0 && (
                        <td
                          style={pinned('body', leads.details).style}
                          className={cn(
                            pinned('body', leads.details).className,
                            'w-7 min-w-7 align-top',
                          )}
                        >
                          <div className="flex h-11 items-center">
                            <button
                              type="button"
                              aria-expanded={expanded}
                              aria-label={t(expanded ? 'hideDetails' : 'showDetails', { n: i + 1 })}
                              onClick={() => toggleOpen(row.id)}
                              className="relative flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5"
                            >
                              <ChevronRight
                                className={cn('transition-transform', expanded && 'rotate-90')}
                              />
                              {detailError && (
                                <span
                                  aria-hidden
                                  className="absolute top-0.5 right-0.5 size-1.5 rounded-full bg-destructive"
                                />
                              )}
                            </button>
                          </div>
                        </td>
                      )}
                      {columns.map((col, j) => (
                        <td
                          key={col.name}
                          data-cell
                          data-row={i}
                          data-col={j}
                          style={j === 0 ? pinned('body', firstLeft).style : undefined}
                          className={cn(
                            'px-1.5 py-1.5 align-top',
                            cellWidth[col.type] ?? 'min-w-36',
                            j === 0 && pinned('body', firstLeft, true).className,
                          )}
                        >
                          {control(row, i, col, j, false)}
                        </td>
                      ))}
                      {!disabled && (
                        <td className="px-1 align-top">
                          <div className="flex h-11 items-center">
                            <ItemMenu
                              label={t('rowLabel', { n: i + 1 })}
                              index={i}
                              count={rows.length}
                              canAdd={canAdd}
                              onAction={(a) => act(i, a)}
                            />
                          </div>
                        </td>
                      )}
                    </tr>
                    {expanded && (
                      <tr data-row={i} className="border-b bg-muted/20" onKeyDown={rowKeys(i)}>
                        <td colSpan={span} className="p-0">
                          <div
                            className="sticky left-0 grid grid-cols-[repeat(auto-fill,minmax(12rem,1fr))] gap-3 px-3 py-3"
                            style={width ? { width: width - 2 } : undefined}
                          >
                            {details.map((d) => control(row, i, d, -1, true))}
                          </div>
                        </td>
                      </tr>
                    )}
                  </Fragment>
                )
              })}
              {!rows.length && (
                <tr>
                  <td
                    colSpan={span}
                    className="px-3 py-6 text-center text-sm text-muted-foreground"
                  >
                    {canAdd ? t('noRowsHint') : t('noRows')}
                  </td>
                </tr>
              )}
            </tbody>
            {totals && (
              <tfoot>
                <tr className="border-t bg-muted/30 font-medium">
                  {!disabled && <td {...pinned('foot', leads.check)} />}
                  <td
                    style={pinned('foot', leads.number).style}
                    className={cn(
                      pinned('foot', leads.number).className,
                      'px-1 py-1.5 text-center text-xs text-muted-foreground',
                    )}
                  >
                    Σ
                  </td>
                  {details.length > 0 && <td {...pinned('foot', leads.details)} />}
                  {columns.map((c, j) => (
                    <td
                      key={c.name}
                      style={j === 0 ? pinned('foot', firstLeft).style : undefined}
                      className={cn(
                        'px-3 py-1.5 tabular-nums',
                        j === 0 && pinned('foot', firstLeft, true).className,
                      )}
                    >
                      {totals.find((t) => t.name === c.name)?.text ?? ''}
                    </td>
                  ))}
                  {!disabled && <td />}
                </tr>
              </tfoot>
            )}
          </table>
        </div>
      )}

      {undo && (
        <UndoBar
          message={undo.message}
          onUndo={() => {
            undo.restore()
            dismiss()
          }}
          onDismiss={dismiss}
        />
      )}

      {!disabled && (
        <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
          {canAdd && (
            <button
              type="button"
              onClick={add}
              className="flex h-7 items-center gap-1 rounded-lg px-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5"
            >
              <Plus /> {field.addLabel ?? t('addRow')}
            </button>
          )}
          <span aria-live="polite" className="text-xs text-muted-foreground">
            {note || (rows.length > 0 && !compact ? t('pasteTip') : '')}
          </span>
        </div>
      )}
    </div>
  )
}
schema.ts
import {
  formatDate,
  formatDateRange,
  formatDateTime,
  formatMonth,
  formatTime,
  thisMonthIso,
  todayIso,
} from './dates'
import { defaultT, type T } from './i18n'

export type BuiltInFieldType =
  | 'text'
  | 'textarea'
  | 'email'
  | 'phone'
  | 'url'
  | 'number'
  | 'currency'
  | 'date'
  | 'time'
  | 'month'
  | 'select'
  | 'multiselect'
  | 'toggle'
  | 'radio'
  | 'list'
  | 'file'
  | 'rows'
  | 'lookup'
  | 'address'
  | 'daterange'
  | 'datetime'
  | 'percent'
  | 'duration'
  | 'richtext'
  | 'signature'
  | 'group'

export type FieldType = BuiltInFieldType | (string & {})

export type OptionObject = {
  value: string
  label: string
  description?: string
  avatar?: string
  color?: string
}
export type Option = string | OptionObject

export type FileItem = {
  id: string
  name: string
  size: number
  type: string
  url?: string
  status?: 'uploading' | 'done' | 'error'
  progress?: number
  error?: string
}
export type Row = { id: string; [column: string]: FieldValue | undefined }

export type FieldValue = string | number | boolean | null | string[] | FileItem[] | Row[]
export type FormValues = Record<string, FieldValue | undefined>
export type FormErrors = Record<string, string>

export type Condition =
  | {
      field: string
      is?: FieldValue | FieldValue[]
      not?: FieldValue | FieldValue[]
      filled?: boolean
    }
  | { any: Condition[] }
  | { all: Condition[] }

export type Rule =
  | 'required'
  | 'type'
  | 'min'
  | 'max'
  | 'minLength'
  | 'maxLength'
  | 'pattern'
  | 'maxSize'
  | 'accept'
  | 'sum'

export type FieldDef = {
  name: string
  label: string
  type: FieldType
  required?: boolean
  requiredWhen?: Condition | Condition[]
  visibleWhen?: Condition | Condition[]
  readOnly?: boolean
  readOnlyWhen?: Condition | Condition[]
  compute?: (values: FormValues) => FieldValue
  options?: Option[]
  display?: 'segmented' | 'cards' | 'checkboxes'
  creatable?: boolean
  placeholder?: string
  hint?: string
  span?: 1 | 2 | 3 | 4 | 'full'
  min?: number | string
  max?: number | string
  step?: number
  minLength?: number
  maxLength?: number
  pattern?: string
  accept?: string
  maxSize?: number
  multiple?: boolean
  columns?: FieldDef[]
  details?: FieldDef[]
  fields?: FieldDef[]
  itemLabel?: string
  titleField?: string
  groupColumns?: 1 | 2 | 3
  totals?: string[]
  sum?: { column: string; equals?: number; min?: number; max?: number }
  addLabel?: string
  messages?: Partial<Record<Rule, string>>
  search?: (query: string) => Promise<OptionObject[]>
  decimals?: number
  defaultCountry?: string
  itemLabelPlural?: string
  permissions?: Permissions
  props?: Record<string, unknown>
}

export type Permissions = { view?: string[]; edit?: string[] }

export type Context = { t?: T; locale?: string; roles?: string[] }

export type FormSection = {
  id: string
  title: string
  description?: string
  icon?: React.ReactNode
  columns?: 1 | 2 | 3 | 4
  span?: 1 | 'full'
  defaultOpen?: boolean
  visibleWhen?: Condition | Condition[]
  permissions?: Permissions
  fields: FieldDef[]
}

export type CustomFieldType = {
  validate?: (
    value: FieldValue | undefined,
    field: FieldDef,
    values: FormValues,
  ) => string | undefined
  isEmpty?: (value: FieldValue | undefined) => boolean
  format?: (value: FieldValue | undefined, field: FieldDef) => string
}

export type FieldTypeMap = Record<string, CustomFieldType>

export type Change = { id: string; label: string; from: string; to: string }

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const URL_PATTERN = /^(https?:\/\/)?[\w-]+(\.[\w-]+)+(\/\S*)?$/

export function normalizeOptions(options: Option[] = []): OptionObject[] {
  return options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o))
}

const known = new Map<string, OptionObject>()

export function rememberOptions(options: OptionObject[]) {
  for (const o of options) known.set(o.value, o)
}

export function findOption(field: Pick<FieldDef, 'options'>, value: string) {
  return normalizeOptions(field.options).find((o) => o.value === value) ?? known.get(value)
}

export function optionLabel(field: Pick<FieldDef, 'options'>, value: string) {
  return findOption(field, value)?.label ?? value
}

export function plainText(html: string) {
  return html
    .replace(/<(br|\/p|\/li|\/div)[^>]*>/gi, '\n')
    .replace(/<[^>]+>/g, '')
    .replace(/&nbsp;/g, ' ')
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/\n{2,}/g, '\n')
    .trim()
}

export function parseDuration(text: string): number | null {
  const t = text.trim().toLowerCase().replace(/\s+/g, '')
  if (!t) return null
  if (/^\d+(\.\d+)?$/.test(t)) return Math.round(Number(t))
  const clock = /^(\d+):([0-5]\d)$/.exec(t)
  if (clock) return Number(clock[1]) * 60 + Number(clock[2])
  const parts = /^(?:(\d+(?:\.\d+)?)d)?(?:(\d+(?:\.\d+)?)h)?(?:(\d+)m(?:in)?)?$/.exec(t)
  if (!parts || !(parts[1] || parts[2] || parts[3])) return null
  return Math.round(
    Number(parts[1] ?? 0) * 1440 + Number(parts[2] ?? 0) * 60 + Number(parts[3] ?? 0),
  )
}

export function showDuration(minutes: number) {
  if (!minutes) return '0m'
  const d = Math.floor(minutes / 1440)
  const h = Math.floor((minutes % 1440) / 60)
  const m = minutes % 60
  return [d && `${d}d`, h && `${h}h`, m && `${m}m`].filter(Boolean).join(' ')
}

export function phoneDigits(value: string) {
  return value.replace(/\D/g, '')
}

export function formatNumber(value: number, decimals?: number, locale?: string) {
  return new Intl.NumberFormat(locale ?? 'en-US', {
    minimumFractionDigits: decimals ?? 0,
    maximumFractionDigits: decimals ?? 20,
  }).format(value)
}

const isRichEmpty = (value: FieldValue | undefined) =>
  typeof value === 'string' && !plainText(value) && !/<img/i.test(value)

export function isEmptyValue(value: FieldValue | undefined) {
  return (
    value === undefined ||
    value === null ||
    value === '' ||
    (Array.isArray(value) && value.length === 0)
  )
}

const matches = (value: FieldValue | undefined, expected: FieldValue | FieldValue[]) => {
  const wanted = Array.isArray(expected) ? expected : [expected]
  if (Array.isArray(value)) return value.some((v) => wanted.includes(v as never))
  return wanted.some((w) => w === value || (w !== null && String(w) === String(value ?? '')))
}

export function evaluate(
  condition: Condition | Condition[] | undefined,
  values: FormValues,
): boolean {
  if (!condition) return true
  if (Array.isArray(condition)) return condition.every((c) => evaluate(c, values))
  if ('any' in condition) return condition.any.some((c) => evaluate(c, values))
  if ('all' in condition) return condition.all.every((c) => evaluate(c, values))
  const value = values[condition.field]
  if (condition.filled !== undefined && isEmptyValue(value) === condition.filled) return false
  if (condition.is !== undefined && !matches(value, condition.is)) return false
  if (condition.not !== undefined && matches(value, condition.not)) return false
  return true
}

const allowed = (list: string[] | undefined, roles: string[] | undefined) =>
  !list || list.length === 0 || (!!roles && roles.some((r) => list.includes(r)))

export const canView = (item: { permissions?: Permissions }, roles?: string[]) =>
  allowed(item.permissions?.view, roles)

export const canEdit = (item: { permissions?: Permissions }, roles?: string[]) =>
  allowed(item.permissions?.edit, roles)

export const isVisible = (
  item: { visibleWhen?: FieldDef['visibleWhen']; permissions?: Permissions },
  values: FormValues,
  roles?: string[],
) => canView(item, roles) && evaluate(item.visibleWhen, values)

export const isRequired = (field: FieldDef, values: FormValues) =>
  !!field.required || (field.requiredWhen !== undefined && evaluate(field.requiredWhen, values))

export const isReadOnly = (field: FieldDef, values: FormValues, roles?: string[]) =>
  !!field.readOnly ||
  !!field.compute ||
  !canEdit(field, roles) ||
  (field.readOnlyWhen !== undefined && evaluate(field.readOnlyWhen, values))

export function visibleFields(sections: FormSection[], values: FormValues, roles?: string[]) {
  return sections
    .filter((s) => isVisible(s, values, roles))
    .flatMap((s) =>
      canEdit(s, roles) ? s.fields : s.fields.map((f) => ({ ...f, readOnly: true })),
    )
    .filter((f) => isVisible(f, values, roles))
}

export function editableFields(sections: FormSection[], values: FormValues, roles?: string[]) {
  return visibleFields(sections, values, roles).filter(
    (f) => !isReadOnly(f, values, roles) || f.compute,
  )
}

export const itemFields = (field: FieldDef) =>
  field.type === 'group'
    ? (field.fields ?? [])
    : [...(field.columns ?? []), ...(field.details ?? [])]

export const isNested = (field: FieldDef) => field.type === 'rows' || field.type === 'group'

export const scope = (values: FormValues, item: Row): FormValues => ({ ...values, ...item })

export function computeItems(field: FieldDef, items: Row[], values: FormValues): Row[] {
  return items.map((item) => {
    let next = item
    for (const sub of itemFields(field)) {
      if (sub.compute) {
        const v = sub.compute(scope(values, next))
        if (JSON.stringify(v) !== JSON.stringify(next[sub.name])) next = { ...next, [sub.name]: v }
      }
      const nested = next[sub.name]
      if (isNested(sub) && Array.isArray(nested)) {
        const inner = computeItems(sub, nested as Row[], scope(values, next))
        if (JSON.stringify(inner) !== JSON.stringify(nested)) next = { ...next, [sub.name]: inner }
      }
    }
    return next
  })
}

export function applyComputed(sections: FormSection[], values: FormValues): FormValues {
  let next = values
  for (const field of sections.flatMap((s) => s.fields)) {
    if (field.compute) {
      const value = field.compute(next)
      if (JSON.stringify(value) !== JSON.stringify(next[field.name]))
        next = { ...next, [field.name]: value }
    }
    const items = next[field.name]
    if (isNested(field) && Array.isArray(items)) {
      const computed = computeItems(field, items as Row[], next)
      if (JSON.stringify(computed) !== JSON.stringify(items))
        next = { ...next, [field.name]: computed }
    }
  }
  return next
}

const limitOf = (limit: number | string, type: FieldType) =>
  limit === 'today' ? (type === 'month' ? thisMonthIso() : todayIso()) : String(limit)

export function showDate(iso: string, locale?: string) {
  return formatDate(iso, locale)
}
export function showMonth(iso: string, locale?: string) {
  return formatMonth(iso, locale)
}
export function showTime(hhmm: string, locale?: string) {
  return formatTime(hhmm, locale)
}
export function showDateTime(value: string, locale?: string) {
  return formatDateTime(value, locale)
}
export function showSize(bytes: number) {
  if (bytes < 1024) return `${bytes} B`
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
  return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}

const showLimit = (limit: string, type: FieldType, locale?: string) =>
  type === 'date'
    ? showDate(limit, locale)
    : type === 'month'
      ? showMonth(limit, locale)
      : type === 'time'
        ? showTime(limit, locale)
        : limit

function acceptsFile(accept: string, file: FileItem) {
  return accept
    .split(',')
    .map((a) => a.trim().toLowerCase())
    .some((a) =>
      a.startsWith('.')
        ? file.name.toLowerCase().endsWith(a)
        : a.endsWith('/*')
          ? file.type.startsWith(a.slice(0, -1))
          : file.type === a,
    )
}

function nounFor(field: FieldDef, count: number, t: T) {
  if (field.type === 'group') {
    const one = (field.itemLabel ?? t('noun.item_one')).toLowerCase()
    return count === 1 ? one : (field.itemLabelPlural ?? `${one}s`).toLowerCase()
  }
  const key =
    field.type === 'rows'
      ? 'noun.row'
      : field.type === 'file'
        ? 'noun.file'
        : field.type === 'list'
          ? 'noun.item'
          : 'noun.option'
  return t(key, { count })
}

export function validateField(
  field: FieldDef,
  value: FieldValue | undefined,
  values: FormValues = {},
  fieldTypes: FieldTypeMap = {},
  context: Context = {},
): string | undefined {
  const t = context.t ?? defaultT
  const { locale } = context
  const say = (rule: Rule, key: string, vars: Record<string, unknown> = {}) => {
    const all = { label: field.label, ...vars }
    const template = field.messages?.[rule]
    if (!template) return t(key, all)
    return template.replace(/\{(\w+)\}/g, (_, k: string) =>
      String(all[k as keyof typeof all] ?? field[k as keyof FieldDef] ?? ''),
    )
  }
  const count = (rule: Rule, key: 'minCount' | 'maxCount', n: number) =>
    say(rule, key, { count: n, noun: nounFor(field, n, t), min: n, max: n })

  const custom = fieldTypes[field.type]
  const empty = custom?.isEmpty
    ? custom.isEmpty(value)
    : isEmptyValue(value) ||
      (field.type === 'richtext' && isRichEmpty(value)) ||
      (field.type === 'daterange' && value === '/')

  if (empty && (field.type === 'rows' || field.type === 'group') && (field.minLength ?? 0) > 0)
    return count('minLength', 'minCount', field.minLength!)
  if (empty) return isRequired(field, values) ? say('required', 'required') : undefined
  if (custom?.validate) {
    const error = custom.validate(value, field, values)
    if (error) return error
  }

  const { min, max, minLength, maxLength, type } = field

  if (type === 'file' && Array.isArray(value)) {
    const files = value as FileItem[]
    if (files.some((f) => f.status === 'uploading')) return t('uploading')
    if (files.some((f) => f.status === 'error')) return t('uploadFailed')
    if (maxLength !== undefined && files.length > maxLength)
      return count('maxLength', 'maxCount', maxLength)
    const big = field.maxSize ? files.find((f) => f.size > field.maxSize!) : undefined
    if (big)
      return say('maxSize', 'maxSize', {
        name: big.name,
        max: showSize(field.maxSize!),
        maxSize: showSize(field.maxSize!),
      })
    const wrong = field.accept ? files.find((f) => !acceptsFile(field.accept!, f)) : undefined
    if (wrong) return say('accept', 'accept', { name: wrong.name })
    return undefined
  }

  if ((type === 'rows' || type === 'group') && Array.isArray(value)) {
    const rows = value as Row[]
    if (minLength !== undefined && rows.length < minLength)
      return count('minLength', 'minCount', minLength)
    if (maxLength !== undefined && rows.length > maxLength)
      return count('maxLength', 'maxCount', maxLength)
    if (field.sum) {
      const { column, equals, min: lo, max: hi } = field.sum
      const total = Math.round(rows.reduce((s, r) => s + (Number(r[column]) || 0), 0) * 100) / 100
      const label = field.columns?.find((c) => c.name === column)?.label ?? column
      if (equals !== undefined && total !== equals)
        return say('sum', 'sumEquals', { label, equals, total })
      if (lo !== undefined && total < lo) return say('sum', 'sumMin', { label, min: lo, total })
      if (hi !== undefined && total > hi) return say('sum', 'sumMax', { label, max: hi, total })
    }
    return undefined
  }

  if (Array.isArray(value)) {
    if (minLength !== undefined && value.length < minLength)
      return count('minLength', 'minCount', minLength)
    if (maxLength !== undefined && value.length > maxLength)
      return count('maxLength', 'maxCount', maxLength)
    return undefined
  }

  if (type === 'duration') {
    const n = Number(value)
    if (Number.isNaN(n)) return say('type', 'duration')
    if (min !== undefined && n < Number(min))
      return say('min', 'minNumber', { min: showDuration(Number(min)) })
    if (max !== undefined && n > Number(max))
      return say('max', 'maxNumber', { max: showDuration(Number(max)) })
    return undefined
  }

  if (type === 'number' || type === 'currency' || type === 'percent' || typeof value === 'number') {
    const n = Number(value)
    if (Number.isNaN(n)) return say('type', 'number')
    if (min !== undefined && n < Number(min))
      return say('min', 'minNumber', { min: formatNumber(Number(min), undefined, locale) })
    if (max !== undefined && n > Number(max))
      return say('max', 'maxNumber', { max: formatNumber(Number(max), undefined, locale) })
    return undefined
  }

  if (typeof value !== 'string') return undefined

  if (type === 'phone') {
    const digits = phoneDigits(value)
    return digits.length < 6 || digits.length > 15 ? say('type', 'phone') : undefined
  }

  if (type === 'richtext') {
    const length = plainText(value).length
    if (maxLength !== undefined && length > maxLength)
      return say('maxLength', 'maxChars', { count: maxLength, max: maxLength })
    if (minLength !== undefined && length < minLength)
      return say('minLength', 'minChars', { count: minLength, min: minLength })
    return undefined
  }

  if (type === 'daterange') {
    const [start, end] = value.split('/')
    if (!start || !end) return say('type', 'rangeIncomplete')
    if (end < start) return say('type', 'rangeOrder')
    if (min !== undefined && start < limitOf(min, 'date'))
      return say('min', 'rangeMin', { min: showDate(limitOf(min, 'date'), locale) })
    if (max !== undefined && end > limitOf(max, 'date'))
      return say('max', 'rangeMax', { max: showDate(limitOf(max, 'date'), locale) })
    return undefined
  }

  if (type === 'datetime') {
    if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(value)) return say('type', 'datetime')
    if (min !== undefined && value < String(min))
      return say('min', 'minTime', { min: showDateTime(String(min), locale) })
    if (max !== undefined && value > String(max))
      return say('max', 'maxTime', { max: showDateTime(String(max), locale) })
    return undefined
  }

  if (type === 'email' && !EMAIL.test(value)) return say('type', 'email')
  if (type === 'url' && !URL_PATTERN.test(value)) return say('type', 'url')

  if (type === 'date' || type === 'time' || type === 'month') {
    const [minKey, maxKey] = type === 'time' ? ['minTime', 'maxTime'] : ['minDate', 'maxDate']
    if (min !== undefined && value < limitOf(min, type))
      return say('min', minKey, { min: showLimit(limitOf(min, type), type, locale) })
    if (max !== undefined && value > limitOf(max, type))
      return say('max', maxKey, { max: showLimit(limitOf(max, type), type, locale) })
    return undefined
  }

  if (minLength !== undefined && value.length < minLength)
    return say('minLength', 'minChars', { count: minLength, min: minLength })
  if (maxLength !== undefined && value.length > maxLength)
    return say('maxLength', 'maxChars', { count: maxLength, max: maxLength })
  if (field.pattern && !new RegExp(field.pattern).test(value)) return say('pattern', 'pattern')

  return undefined
}

function validateItems(
  field: FieldDef,
  items: Row[],
  values: FormValues,
  path: string,
  fieldTypes: FieldTypeMap,
  context: Context,
  errors: FormErrors,
) {
  items.forEach((item, i) => {
    const ctx = scope(values, item)
    for (const sub of itemFields(field)) {
      if (!isVisible(sub, ctx, context.roles)) continue
      const value = sub.compute ? sub.compute(ctx) : item[sub.name]
      const key = `${path}.${i}.${sub.name}`
      const error = validateField(sub, value, ctx, fieldTypes, context)
      if (error) errors[key] = error
      if (isNested(sub) && Array.isArray(value))
        validateItems(sub, value as Row[], ctx, key, fieldTypes, context, errors)
    }
  })
}

export function validateFields(
  fields: FieldDef[],
  values: FormValues,
  fieldTypes: FieldTypeMap = {},
  context: Context = {},
): FormErrors {
  const errors: FormErrors = {}
  for (const field of fields) {
    if (!isVisible(field, values, context.roles)) continue
    const value = field.compute ? field.compute(values) : values[field.name]
    const error = validateField(field, value, values, fieldTypes, context)
    if (error) errors[field.name] = error
    if (isNested(field) && Array.isArray(value))
      validateItems(field, value as Row[], values, field.name, fieldTypes, context, errors)
  }
  return errors
}

export function validateRecord(
  sections: FormSection[],
  values: FormValues,
  fieldTypes: FieldTypeMap = {},
  context: Context = {},
) {
  return validateFields(visibleFields(sections, values, context.roles), values, fieldTypes, context)
}

export function formatValue(
  field: FieldDef,
  value: FieldValue | undefined,
  fieldTypes: FieldTypeMap = {},
  context: Context = {},
): string {
  const t = context.t ?? defaultT
  const { locale } = context
  const custom = fieldTypes[field.type]
  if (custom?.format) return custom.format(value, field)
  if (isEmptyValue(value) || (field.type === 'richtext' && isRichEmpty(value))) return t('empty')
  if (typeof value === 'boolean') return value ? t('yes') : t('no')
  if (field.type === 'file') return (value as FileItem[]).map((f) => f.name).join(', ')
  if (field.type === 'rows') return t('rows', { count: (value as Row[]).length })
  if (field.type === 'group') {
    const n = (value as Row[]).length
    return `${n} ${nounFor(field, n, t)}`
  }
  if (Array.isArray(value)) return (value as string[]).map((v) => optionLabel(field, v)).join(', ')
  const text = String(value)
  if (field.type === 'date') return showDate(text, locale)
  if (field.type === 'datetime') return showDateTime(text, locale)
  if (field.type === 'daterange') return formatDateRange(text, locale) || t('empty')
  if (field.type === 'lookup') return optionLabel(field, text)
  if (field.type === 'richtext') return plainText(text)
  if (field.type === 'signature') return t('signed')
  if (field.type === 'duration') return showDuration(Number(text))
  if (field.type === 'percent') return `${formatNumber(Number(text), field.decimals, locale)}%`
  if ((field.type === 'number' || field.type === 'currency') && !Number.isNaN(Number(text)))
    return formatNumber(Number(text), field.decimals, locale)
  if (field.type === 'month') return showMonth(text, locale)
  if (field.type === 'time') return showTime(text, locale)
  if (field.type === 'select' || field.type === 'radio') return optionLabel(field, text)
  return text
}

export function diffValues(
  sections: FormSection[],
  before: FormValues,
  after: FormValues,
  fieldTypes: FieldTypeMap = {},
  context: Context = {},
): Change[] {
  const changes: Change[] = []
  for (const field of visibleFields(sections, after, context.roles)) {
    const a = formatValue(field, before[field.name], fieldTypes, context)
    const b = formatValue(field, after[field.name], fieldTypes, context)
    if (a !== b) changes.push({ id: field.name, label: field.label, from: a, to: b })
    else if (JSON.stringify(before[field.name]) !== JSON.stringify(after[field.name]))
      changes.push({ id: field.name, label: field.label, from: a, to: `${b} *` })
  }
  return changes
}
signature.tsx
'use client'

import { useEffect, useRef, useState } from 'react'
import { Maximize2, Undo2 } from 'lucide-react'

import { cn } from '@/lib/utils'

import { Dialog } from '@/registry/dashboard/components/dialog/dialog'

import { useFormConfig } from './context'

type Point = { x: number; y: number }
type Stroke = Point[]
type Mode = 'draw' | 'type'

const W = 1000
const H = 250
const INK = '#111111'
const FONT = 'italic 96px "Brush Script MT", "Segoe Script", "Snell Roundhand", cursive'

function paint(
  canvas: HTMLCanvasElement,
  strokes: Stroke[],
  typed: string,
  mode: Mode,
  base: HTMLImageElement | null,
) {
  const ctx = canvas.getContext('2d')
  if (!ctx) return
  const scale = canvas.width / W
  ctx.setTransform(1, 0, 0, 1, 0, 0)
  ctx.clearRect(0, 0, canvas.width, canvas.height)
  ctx.setTransform(scale, 0, 0, scale, 0, 0)
  if (base) ctx.drawImage(base, 0, 0, W, H)
  ctx.strokeStyle = INK
  ctx.fillStyle = INK
  if (mode === 'type') {
    if (!typed.trim()) return
    ctx.font = FONT
    ctx.textBaseline = 'middle'
    ctx.fillText(typed, 40, H / 2, W - 80)
    return
  }
  ctx.lineCap = 'round'
  ctx.lineJoin = 'round'
  ctx.lineWidth = 5
  for (const stroke of strokes) {
    if (!stroke.length) continue
    ctx.beginPath()
    ctx.moveTo(stroke[0].x, stroke[0].y)
    if (stroke.length === 1) ctx.lineTo(stroke[0].x + 0.1, stroke[0].y)
    for (let i = 1; i < stroke.length - 1; i++) {
      const mid = { x: (stroke[i].x + stroke[i + 1].x) / 2, y: (stroke[i].y + stroke[i + 1].y) / 2 }
      ctx.quadraticCurveTo(stroke[i].x, stroke[i].y, mid.x, mid.y)
    }
    if (stroke.length > 1) ctx.lineTo(stroke.at(-1)!.x, stroke.at(-1)!.y)
    ctx.stroke()
  }
}

function exportPng(strokes: Stroke[], typed: string, mode: Mode, base: HTMLImageElement | null) {
  const canvas = document.createElement('canvas')
  canvas.width = W * 2
  canvas.height = H * 2
  paint(canvas, strokes, typed, mode, base)
  return canvas.toDataURL('image/png')
}

function Pad({
  strokes,
  typed,
  mode,
  base,
  label,
  onStroke,
  className,
}: {
  strokes: Stroke[]
  typed: string
  mode: Mode
  base: HTMLImageElement | null
  label: string
  onStroke: (stroke: Stroke) => void
  className?: string
}) {
  const canvas = useRef<HTMLCanvasElement>(null)
  const live = useRef<Stroke | null>(null)
  const latest = useRef({ strokes, typed, mode, base })

  useEffect(() => {
    latest.current = { strokes, typed, mode, base }
    if (canvas.current) paint(canvas.current, strokes, typed, mode, base)
  }, [strokes, typed, mode, base])

  useEffect(() => {
    const c = canvas.current
    if (!c) return
    const observer = new ResizeObserver(() => {
      const ratio = window.devicePixelRatio || 1
      c.width = Math.round(c.clientWidth * ratio)
      c.height = Math.round(c.clientHeight * ratio)
      const { strokes: s, typed: t, mode: m, base: b } = latest.current
      paint(c, s, t, m, b)
    })
    observer.observe(c)
    return () => observer.disconnect()
  }, [])

  const point = (e: React.PointerEvent<HTMLCanvasElement>): Point => {
    const rect = e.currentTarget.getBoundingClientRect()
    return {
      x: ((e.clientX - rect.left) / rect.width) * W,
      y: ((e.clientY - rect.top) / rect.height) * H,
    }
  }

  const repaintLive = () => {
    const c = canvas.current
    if (!c || !live.current) return
    paint(c, [...strokes, live.current], typed, mode, base)
  }

  return (
    <canvas
      ref={canvas}
      role="img"
      aria-label={label}
      className={cn(
        'block aspect-[4/1] w-full touch-none',
        mode === 'draw' && 'cursor-crosshair',
        className,
      )}
      onPointerDown={(e) => {
        if (mode !== 'draw') return
        e.currentTarget.setPointerCapture(e.pointerId)
        live.current = [point(e)]
        repaintLive()
      }}
      onPointerMove={(e) => {
        if (!live.current) return
        const events = e.nativeEvent.getCoalescedEvents?.() ?? [e.nativeEvent]
        const rect = e.currentTarget.getBoundingClientRect()
        for (const ev of events)
          live.current.push({
            x: ((ev.clientX - rect.left) / rect.width) * W,
            y: ((ev.clientY - rect.top) / rect.height) * H,
          })
        repaintLive()
      }}
      onPointerUp={() => {
        if (!live.current) return
        const stroke = live.current
        live.current = null
        onStroke(stroke)
      }}
      onPointerCancel={() => {
        live.current = null
        repaintLive()
      }}
    />
  )
}

function Paper({ children, invalid }: { children: React.ReactNode; invalid?: boolean }) {
  return (
    <div
      className={cn(
        'relative overflow-hidden rounded-lg border bg-white text-neutral-900',
        invalid ? 'border-destructive' : 'border-input',
      )}
    >
      {children}
      <span
        aria-hidden
        className="pointer-events-none absolute right-[4%] bottom-[22%] left-[4%] border-b border-dashed border-neutral-300"
      />
    </div>
  )
}

function Controls({
  mode,
  typed,
  canUndo,
  canClear,
  onMode,
  onTyped,
  onUndo,
  onClear,
  children,
}: {
  mode: Mode
  typed: string
  canUndo: boolean
  canClear: boolean
  onMode: (mode: Mode) => void
  onTyped: (text: string) => void
  onUndo: () => void
  onClear: () => void
  children?: React.ReactNode
}) {
  const { t } = useFormConfig()
  return (
    <div className="flex flex-wrap items-center gap-2">
      <div
        role="radiogroup"
        aria-label={t('signatureMethod')}
        className="flex rounded-md bg-muted p-0.5 text-xs"
      >
        {(['draw', 'type'] as const).map((m) => (
          <button
            key={m}
            type="button"
            role="radio"
            aria-checked={mode === m}
            onClick={() => onMode(m)}
            className={cn(
              'h-6 rounded px-2 capitalize',
              mode === m ? 'bg-card font-medium shadow-(--shadow-button)' : 'text-muted-foreground',
            )}
          >
            {t(m)}
          </button>
        ))}
      </div>
      {mode === 'type' && (
        <input
          autoFocus
          value={typed}
          placeholder={t('typeName')}
          aria-label={t('typeName')}
          onChange={(e) => onTyped(e.target.value)}
          className="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 text-sm outline-none focus:border-ring"
        />
      )}
      <div className="ml-auto flex items-center gap-1">
        {mode === 'draw' && (
          <button
            type="button"
            onClick={onUndo}
            disabled={!canUndo}
            className="flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 [&_svg]:size-3"
          >
            <Undo2 /> {t('undo')}
          </button>
        )}
        <button
          type="button"
          onClick={onClear}
          disabled={!canClear}
          className="h-7 rounded-md px-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40"
        >
          {t('clear')}
        </button>
        {children}
      </div>
    </div>
  )
}

export function SignatureField({
  id,
  value,
  disabled,
  invalid,
  describedBy,
  onChange,
}: {
  id: string
  value: string
  disabled?: boolean
  invalid?: boolean
  describedBy?: string
  onChange: (value: string) => void
}) {
  const { t } = useFormConfig()
  const [strokes, setStrokes] = useState<Stroke[]>([])
  const [typed, setTyped] = useState('')
  const [mode, setMode] = useState<Mode>('draw')
  const [base, setBase] = useState<HTMLImageElement | null>(null)
  const [expanded, setExpanded] = useState(false)
  const snapshot = useRef<{
    strokes: Stroke[]
    typed: string
    mode: Mode
    base: HTMLImageElement | null
    value: string
  } | null>(null)
  const loaded = useRef(false)

  useEffect(() => {
    if (loaded.current || !value) return
    loaded.current = true
    const img = new Image()
    img.onload = () => setBase(img)
    img.src = value
  }, [value])

  const update = (next: {
    strokes?: Stroke[]
    typed?: string
    mode?: Mode
    base?: HTMLImageElement | null
  }) => {
    const s = next.strokes ?? strokes
    const t = next.typed ?? typed
    const m = next.mode ?? mode
    const b = next.base === undefined ? base : next.base
    if (next.strokes) setStrokes(s)
    if (next.typed !== undefined) setTyped(t)
    if (next.mode) setMode(m)
    if (next.base !== undefined) setBase(b)
    const empty = m === 'type' ? !t.trim() : !s.length && !b
    onChange(empty ? '' : exportPng(s, t, m, b))
  }

  const clear = () => update({ strokes: [], typed: '', base: null })
  const hasInk = mode === 'type' ? !!typed.trim() : strokes.length > 0 || !!base

  const open = () => {
    snapshot.current = { strokes, typed, mode, base, value }
    setExpanded(true)
  }
  const cancel = () => {
    const s = snapshot.current
    if (s) {
      setStrokes(s.strokes)
      setTyped(s.typed)
      setMode(s.mode)
      setBase(s.base)
      onChange(s.value)
    }
    setExpanded(false)
  }

  if (disabled) {
    return value ? (
      <div className="overflow-hidden rounded-lg border bg-white">
        <img
          id={id}
          src={value}
          alt={t('sign')}
          className="block aspect-[4/1] w-full object-contain"
        />
      </div>
    ) : (
      <span id={id} className="text-sm text-muted-foreground">
        {t('notSigned')}
      </span>
    )
  }

  const controls = (
    <Controls
      mode={mode}
      typed={typed}
      canUndo={strokes.length > 0}
      canClear={hasInk}
      onMode={(m) => update({ mode: m, strokes: [], typed: '', base: null })}
      onTyped={(t) => update({ typed: t })}
      onUndo={() => update({ strokes: strokes.slice(0, -1) })}
      onClear={clear}
    />
  )

  return (
    <div id={id} aria-describedby={describedBy} className="flex flex-col gap-1.5">
      <Paper invalid={invalid}>
        <Pad
          strokes={strokes}
          typed={typed}
          mode={mode}
          base={base}
          label={
            mode === 'draw' ? 'Signature pad. Draw with a mouse, finger or pen.' : 'Typed signature'
          }
          onStroke={(s) => update({ strokes: [...strokes, s] })}
        />
        {!hasInk && mode === 'draw' && (
          <span className="pointer-events-none absolute bottom-[6%] left-[4%] text-xs text-neutral-400">
            {t('signAbove')}
          </span>
        )}
        <button
          type="button"
          aria-label={t('expandSignature')}
          title={t('expand')}
          onClick={open}
          className="absolute top-2 right-2 flex size-7 items-center justify-center rounded-md bg-white/90 text-neutral-500 shadow-sm ring-1 ring-neutral-200 hover:text-neutral-900 [&_svg]:size-3.5"
        >
          <Maximize2 />
        </button>
      </Paper>
      {controls}

      <Dialog
        open={expanded}
        onOpenChange={(next) => !next && cancel()}
        title={t('sign')}
        size="full"
        closeLabel={t('closeWithoutSaving')}
      >
        <div className="flex flex-col gap-3">
          <Paper>
            <Pad
              strokes={strokes}
              typed={typed}
              mode={mode}
              base={base}
              label={t('signaturePad')}
              onStroke={(s) => update({ strokes: [...strokes, s] })}
            />
            {!hasInk && mode === 'draw' && (
              <span className="pointer-events-none absolute bottom-[6%] left-[4%] text-sm text-neutral-400">
                {t('signAbove')}
              </span>
            )}
          </Paper>
          <Controls
            mode={mode}
            typed={typed}
            canUndo={strokes.length > 0}
            canClear={hasInk}
            onMode={(m) => update({ mode: m, strokes: [], typed: '', base: null })}
            onTyped={(t) => update({ typed: t })}
            onUndo={() => update({ strokes: strokes.slice(0, -1) })}
            onClear={clear}
          >
            <button
              type="button"
              data-autofocus
              onClick={() => setExpanded(false)}
              className="ml-1 h-8 rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90"
            >
              {t('done')}
            </button>
          </Controls>
        </div>
      </Dialog>
    </div>
  )
}
upload.ts
export type UploadContext = {
  onProgress: (percent: number) => void
  signal: AbortSignal
}

export type UploadResult = { url: string; id?: string }

export type Uploader = (file: File, context: UploadContext) => Promise<UploadResult>

export function xhrUploader({
  url,
  field = 'file',
  headers,
  method = 'POST',
  parse = (body) => body as UploadResult,
}: {
  url: string
  field?: string
  headers?: Record<string, string>
  method?: 'POST' | 'PUT'
  parse?: (body: unknown) => UploadResult
}): Uploader {
  return (file, { onProgress, signal }) =>
    new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest()
      xhr.open(method, url)
      for (const [k, v] of Object.entries(headers ?? {})) xhr.setRequestHeader(k, v)
      xhr.upload.onprogress = (e) => e.lengthComputable && onProgress((e.loaded / e.total) * 100)
      xhr.onload = () => {
        if (xhr.status < 200 || xhr.status >= 300)
          return reject(new Error(`Upload failed (${xhr.status})`))
        try {
          resolve(parse(xhr.responseText ? JSON.parse(xhr.responseText) : {}))
        } catch (error) {
          reject(error)
        }
      }
      xhr.onerror = () => reject(new Error('Network error'))
      xhr.onabort = () => reject(new DOMException('Upload cancelled', 'AbortError'))
      signal.addEventListener('abort', () => xhr.abort())
      const body = new FormData()
      body.append(field, file)
      xhr.send(method === 'PUT' ? file : body)
    })
}