Skip to content

Vendor onboarding

A compliance form grouped into panels, with country-aware identifiers and document upload.

Businessadvancedvendorcomplianceonboardingibandocuments

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

This exact file renders the preview above.

'use client'

import { Field, Fieldset } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/choice'
import { Select } from '@/components/ui/select'
import { FileUpload } from '@/components/ui/file-upload'
import { Panel } from '@/components/ui/card'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, pattern, required } from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Vendor onboarding
 *
 * A compliance form, grouped into panels so a long list of legal and banking
 * fields reads as three short tasks rather than one wall. Panels are the right
 * tool here precisely because the groups are genuinely different in kind.
 */
export default function VendorOnboardingForm() {
  const form = useDemoForm({
    schema: {
      legalName: { validators: [required('Legal entity name')] },
      tradingName: {},
      registration: {
        validators: [
          required('Registration number'),
          pattern(/^[A-Z0-9-]{6,20}$/i, 'Use 6–20 letters, digits or hyphens.'),
        ],
      },
      country: { initial: 'gb', validators: [required('Country')] },
      contactName: { validators: [required('Contact name')] },
      contactEmail: { validators: [required('Contact email'), email()] },
      iban: {
        validators: [
          required('IBAN'),
          pattern(
            /^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/i,
            'Enter a valid IBAN, for example GB29NWBK60161331926819.',
          ),
        ],
      },
      terms: { initial: 'false' },
    },
  })

  return (
    <FormShell
      title="Vendor onboarding"
      description="Three short sections. Nothing is stored — this is a template demo."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Submit for review"
      submittingLabel="Submitting"
      onReset={form.reset}
      successTitle="Submitted for review"
      width="lg"
    >
      <Panel
        title="Entity"
        description="As registered with the authority in your country."
        headingLevel="h4"
      >
        <div className="flex flex-col gap-stack">
          <Field
            name="legalName"
            label="Legal entity name"
            required
            error={form.error('legalName')}
          >
            {(field) => <Input {...field} {...form.field('legalName')} />}
          </Field>
          <Field
            name="tradingName"
            label="Trading name"
            showOptional
            hint="If different from the legal name."
          >
            {(field) => <Input {...field} {...form.field('tradingName')} />}
          </Field>
          <div className="grid gap-stack sm:grid-cols-2">
            <Field
              name="registration"
              label="Registration number"
              required
              error={form.error('registration')}
            >
              {(field) => (
                <Input {...field} className="font-mono" {...form.field('registration')} />
              )}
            </Field>
            <Field
              name="country"
              label="Country of registration"
              required
              error={form.error('country')}
            >
              {(field) => (
                <Select
                  {...field}
                  {...form.field('country')}
                  options={[
                    { value: 'gb', label: 'United Kingdom' },
                    { value: 'ie', label: 'Ireland' },
                    { value: 'de', label: 'Germany' },
                    { value: 'us', label: 'United States' },
                    { value: 'sg', label: 'Singapore' },
                  ]}
                />
              )}
            </Field>
          </div>
        </div>
      </Panel>

      <Panel
        title="Primary contact"
        description="Who we speak to about invoices."
        headingLevel="h4"
      >
        <div className="grid gap-stack sm:grid-cols-2">
          <Field name="contactName" label="Contact name" required error={form.error('contactName')}>
            {(field) => <Input {...field} autoComplete="name" {...form.field('contactName')} />}
          </Field>
          <Field
            name="contactEmail"
            label="Contact email"
            required
            error={form.error('contactEmail')}
          >
            {(field) => (
              <Input {...field} type="email" autoComplete="email" {...form.field('contactEmail')} />
            )}
          </Field>
        </div>
      </Panel>

      <Panel title="Payment and documents" headingLevel="h4">
        <div className="flex flex-col gap-stack">
          <Field
            name="iban"
            label="IBAN"
            required
            error={form.error('iban')}
            hint="Demo validation only — never enter real banking details."
          >
            {(field) => (
              <Input
                {...field}
                className="font-mono"
                placeholder="GB29NWBK60161331926819"
                {...form.field('iban')}
              />
            )}
          </Field>
          <Field name="documents" label="Registration certificate" showOptional>
            {(field) => (
              <FileUpload
                id={field.id}
                name={field.name}
                aria-describedby={field['aria-describedby']}
                accept="application/pdf"
                hint="A single PDF, up to 10 MB"
                maxSizeBytes={10 * 1024 * 1024}
              />
            )}
          </Field>
        </div>
      </Panel>

      <Fieldset legend="Confirmation" name="terms">
        <Checkbox
          id="vendor-terms"
          name="terms"
          label="The information above is accurate"
          checked={form.values.terms === 'true'}
          onChange={(event) => form.setValue('terms', String(event.target.checked))}
        />
      </Fieldset>
    </FormShell>
  )
}

components/blocks/forms/vendor-onboarding.tsx

'use client'

import { Field, Fieldset } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/choice'
import { Select } from '@/components/ui/select'
import { FileUpload } from '@/components/ui/file-upload'
import { Panel } from '@/components/ui/card'
import { useDemoForm } from '@/hooks/use-demo-form'
import { email, pattern, required } from '@/lib/validation'
import { FormShell } from './_shell'

/**
 * Vendor onboarding
 *
 * A compliance form, grouped into panels so a long list of legal and banking
 * fields reads as three short tasks rather than one wall. Panels are the right
 * tool here precisely because the groups are genuinely different in kind.
 */
export default function VendorOnboardingForm() {
  const form = useDemoForm({
    schema: {
      legalName: { validators: [required('Legal entity name')] },
      tradingName: {},
      registration: {
        validators: [
          required('Registration number'),
          pattern(/^[A-Z0-9-]{6,20}$/i, 'Use 6–20 letters, digits or hyphens.'),
        ],
      },
      country: { initial: 'gb', validators: [required('Country')] },
      contactName: { validators: [required('Contact name')] },
      contactEmail: { validators: [required('Contact email'), email()] },
      iban: {
        validators: [
          required('IBAN'),
          pattern(
            /^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/i,
            'Enter a valid IBAN, for example GB29NWBK60161331926819.',
          ),
        ],
      },
      terms: { initial: 'false' },
    },
  })

  return (
    <FormShell
      title="Vendor onboarding"
      description="Three short sections. Nothing is stored — this is a template demo."
      formRef={form.formRef}
      onSubmit={form.handleSubmit}
      status={form.status}
      serverError={form.serverError}
      invalidCount={form.invalidCount}
      submitted={form.submitted}
      submitLabel="Submit for review"
      submittingLabel="Submitting"
      onReset={form.reset}
      successTitle="Submitted for review"
      width="lg"
    >
      <Panel
        title="Entity"
        description="As registered with the authority in your country."
        headingLevel="h4"
      >
        <div className="flex flex-col gap-stack">
          <Field
            name="legalName"
            label="Legal entity name"
            required
            error={form.error('legalName')}
          >
            {(field) => <Input {...field} {...form.field('legalName')} />}
          </Field>
          <Field
            name="tradingName"
            label="Trading name"
            showOptional
            hint="If different from the legal name."
          >
            {(field) => <Input {...field} {...form.field('tradingName')} />}
          </Field>
          <div className="grid gap-stack sm:grid-cols-2">
            <Field
              name="registration"
              label="Registration number"
              required
              error={form.error('registration')}
            >
              {(field) => (
                <Input {...field} className="font-mono" {...form.field('registration')} />
              )}
            </Field>
            <Field
              name="country"
              label="Country of registration"
              required
              error={form.error('country')}
            >
              {(field) => (
                <Select
                  {...field}
                  {...form.field('country')}
                  options={[
                    { value: 'gb', label: 'United Kingdom' },
                    { value: 'ie', label: 'Ireland' },
                    { value: 'de', label: 'Germany' },
                    { value: 'us', label: 'United States' },
                    { value: 'sg', label: 'Singapore' },
                  ]}
                />
              )}
            </Field>
          </div>
        </div>
      </Panel>

      <Panel
        title="Primary contact"
        description="Who we speak to about invoices."
        headingLevel="h4"
      >
        <div className="grid gap-stack sm:grid-cols-2">
          <Field name="contactName" label="Contact name" required error={form.error('contactName')}>
            {(field) => <Input {...field} autoComplete="name" {...form.field('contactName')} />}
          </Field>
          <Field
            name="contactEmail"
            label="Contact email"
            required
            error={form.error('contactEmail')}
          >
            {(field) => (
              <Input {...field} type="email" autoComplete="email" {...form.field('contactEmail')} />
            )}
          </Field>
        </div>
      </Panel>

      <Panel title="Payment and documents" headingLevel="h4">
        <div className="flex flex-col gap-stack">
          <Field
            name="iban"
            label="IBAN"
            required
            error={form.error('iban')}
            hint="Demo validation only — never enter real banking details."
          >
            {(field) => (
              <Input
                {...field}
                className="font-mono"
                placeholder="GB29NWBK60161331926819"
                {...form.field('iban')}
              />
            )}
          </Field>
          <Field name="documents" label="Registration certificate" showOptional>
            {(field) => (
              <FileUpload
                id={field.id}
                name={field.name}
                aria-describedby={field['aria-describedby']}
                accept="application/pdf"
                hint="A single PDF, up to 10 MB"
                maxSizeBytes={10 * 1024 * 1024}
              />
            )}
          </Field>
        </div>
      </Panel>

      <Fieldset legend="Confirmation" name="terms">
        <Checkbox
          id="vendor-terms"
          name="terms"
          label="The information above is accurate"
          checked={form.values.terms === 'true'}
          onChange={(event) => form.setValue('terms', String(event.target.checked))}
        />
      </Fieldset>
    </FormShell>
  )
}

components/ui/card.tsx

import type { HTMLAttributes, ReactNode, ElementType } from 'react'
import { cn } from '@/lib/cn'

/**
 * Card & Panel
 *
 * Two containment primitives with deliberately different jobs:
 *
 *   Card  — a discrete, often interactive record in a collection.
 *   Panel — a titled region of a page, with an optional header action row.
 *
 * Keeping them separate is what stops the library from degenerating into
 * "everything is a rounded box with a shadow".
 */

export interface CardProps extends HTMLAttributes<HTMLDivElement> {
  as?: ElementType
  /** Forwarded when `as` renders a link. */
  href?: string
  /** `outline` is the default; `raised` adds elevation; `sunken` insets. */
  tone?: 'outline' | 'raised' | 'sunken' | 'ghost' | 'accent'
  /** Adds hover affordance. Only use when the whole card is a link/button. */
  interactive?: boolean
  padding?: 'none' | 'sm' | 'md' | 'lg'
}

const cardTones = {
  outline: 'bg-surface border border-line',
  raised: 'bg-surface-raised border border-line shadow-sm',
  sunken: 'bg-surface-sunken border border-line-subtle',
  ghost: 'bg-transparent border border-transparent',
  accent: 'bg-accent-soft border border-accent-line',
} as const

const cardPadding = {
  none: 'p-0',
  sm: 'p-3',
  md: 'p-card',
  lg: 'p-6 sm:p-8',
} as const

export function Card({
  as: Tag = 'div',
  tone = 'outline',
  interactive = false,
  padding = 'md',
  className,
  children,
  ...props
}: CardProps) {
  return (
    <Tag
      className={cn(
        'rounded-lg',
        cardTones[tone],
        cardPadding[padding],
        interactive &&
          'transition-[border-color,box-shadow,background-color] duration-150 ease-standard hover:border-line-strong hover:shadow-sm',
        className,
      )}
      {...props}
    >
      {children}
    </Tag>
  )
}

export function CardHeader({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
  return (
    <div className={cn('flex items-start justify-between gap-4', className)} {...props}>
      {children}
    </div>
  )
}

export function CardTitle({
  as: Tag = 'h3',
  className,
  children,
  ...props
}: HTMLAttributes<HTMLHeadingElement> & { as?: ElementType }) {
  return (
    <Tag className={cn('text-md leading-snug font-semibold text-ink-strong', className)} {...props}>
      {children}
    </Tag>
  )
}

export function CardDescription({
  className,
  children,
  ...props
}: HTMLAttributes<HTMLParagraphElement>) {
  return (
    <p className={cn('text-sm leading-normal text-ink-muted', className)} {...props}>
      {children}
    </p>
  )
}

export function CardFooter({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
  return (
    <div
      className={cn(
        'mt-4 flex flex-wrap items-center gap-3 border-t border-line-subtle pt-4',
        className,
      )}
      {...props}
    >
      {children}
    </div>
  )
}

export interface PanelProps extends Omit<HTMLAttributes<HTMLElement>, 'title'> {
  title: ReactNode
  description?: ReactNode
  /** Rendered on the right of the panel header. */
  action?: ReactNode
  /** Removes body padding — for tables and lists that manage their own. */
  flush?: boolean
  as?: ElementType
  headingLevel?: 'h2' | 'h3' | 'h4'
}

export function Panel({
  title,
  description,
  action,
  flush = false,
  as: Tag = 'section',
  headingLevel: Heading = 'h3',
  className,
  children,
  ...props
}: PanelProps) {
  return (
    <Tag
      className={cn('overflow-hidden rounded-lg border border-line bg-surface', className)}
      {...props}
    >
      <div className="flex flex-wrap items-start justify-between gap-3 border-b border-line-subtle bg-surface-sunken px-4 py-3">
        <div className="min-w-0">
          <Heading className="text-sm font-semibold text-ink-strong">{title}</Heading>
          {description ? <p className="mt-0.5 text-xs text-ink-muted">{description}</p> : null}
        </div>
        {action ? <div className="flex shrink-0 items-center gap-2">{action}</div> : null}
      </div>
      <div className={cn(flush ? '' : 'p-card')}>{children}</div>
    </Tag>
  )
}

components/ui/file-upload.tsx

'use client'

import { useRef, useState, type DragEvent } from 'react'
import { UploadCloud, File as FileIcon, X } from 'lucide-react'
import { cn } from '@/lib/cn'
import { Button } from './button'

/**
 * FileUpload
 *
 * A drop zone that is genuinely operable from the keyboard: the visible
 * surface is a `<button>` that opens the native picker, so Space and Enter
 * work, and the file input itself stays in the DOM for form submission.
 *
 * Nothing is uploaded — this is a template demo. Selected files are listed
 * locally and can be removed.
 */
export interface FileUploadProps {
  name?: string
  id?: string
  accept?: string
  multiple?: boolean
  disabled?: boolean
  /** Human-readable constraint, e.g. "PNG or PDF, up to 10 MB". */
  hint?: string
  maxSizeBytes?: number
  className?: string
  'aria-describedby'?: string
  onFilesChange?: (files: File[]) => void
}

function formatBytes(bytes: number): string {
  if (bytes < 1024) return `${bytes} B`
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}

export function FileUpload({
  name = 'files',
  id = 'file-upload',
  accept,
  multiple = false,
  disabled = false,
  hint,
  maxSizeBytes,
  className,
  onFilesChange,
  ...aria
}: FileUploadProps) {
  const inputRef = useRef<HTMLInputElement>(null)
  const [files, setFiles] = useState<File[]>([])
  const [dragging, setDragging] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const accept_ = (incoming: FileList | null) => {
    if (!incoming) return
    const next = Array.from(incoming)
    const oversize = maxSizeBytes ? next.find((file) => file.size > maxSizeBytes) : undefined
    if (oversize) {
      setError(`${oversize.name} is larger than ${formatBytes(maxSizeBytes ?? 0)}.`)
      return
    }
    setError(null)
    const merged = multiple ? [...files, ...next] : next.slice(0, 1)
    setFiles(merged)
    onFilesChange?.(merged)
  }

  const onDrop = (event: DragEvent<HTMLDivElement>) => {
    event.preventDefault()
    setDragging(false)
    if (disabled) return
    accept_(event.dataTransfer.files)
  }

  const remove = (index: number) => {
    const next = files.filter((_, i) => i !== index)
    setFiles(next)
    onFilesChange?.(next)
    if (inputRef.current) inputRef.current.value = ''
  }

  return (
    <div className={cn('flex flex-col gap-3', className)}>
      <div
        onDragOver={(event) => {
          event.preventDefault()
          if (!disabled) setDragging(true)
        }}
        onDragLeave={() => setDragging(false)}
        onDrop={onDrop}
        className={cn(
          'rounded-md border border-dashed transition-colors duration-150 ease-standard',
          dragging ? 'border-accent bg-accent-soft' : 'border-line-strong bg-surface-sunken',
          disabled && 'cursor-not-allowed opacity-60',
        )}
      >
        <button
          type="button"
          disabled={disabled}
          onClick={() => inputRef.current?.click()}
          aria-describedby={aria['aria-describedby']}
          className="flex w-full flex-col items-center gap-2 px-4 py-8 text-center disabled:cursor-not-allowed"
        >
          <UploadCloud className="size-6 text-ink-subtle" aria-hidden="true" />
          <span className="text-sm font-medium text-ink">
            Drop {multiple ? 'files' : 'a file'} here, or{' '}
            <span className="text-accent underline underline-offset-4">browse</span>
          </span>
          {hint ? <span className="text-xs text-ink-muted">{hint}</span> : null}
        </button>
        <input
          ref={inputRef}
          id={id}
          name={name}
          type="file"
          accept={accept}
          multiple={multiple}
          disabled={disabled}
          className="sr-only"
          onChange={(event) => accept_(event.target.files)}
        />
      </div>

      {error ? (
        <p role="alert" className="text-xs font-medium text-danger">
          {error}
        </p>
      ) : null}

      {files.length > 0 ? (
        <ul className="flex flex-col gap-1.5" aria-label="Selected files">
          {files.map((file, index) => (
            <li
              key={`${file.name}-${index}`}
              className="flex items-center gap-2.5 rounded-md border border-line bg-surface px-3 py-2 text-sm"
            >
              <FileIcon className="size-4 shrink-0 text-ink-subtle" aria-hidden="true" />
              <span className="min-w-0 flex-1 truncate text-ink">{file.name}</span>
              <span className="shrink-0 font-mono text-xs text-ink-muted tabular-nums">
                {formatBytes(file.size)}
              </span>
              <Button
                variant="ghost"
                size="icon-sm"
                onClick={() => remove(index)}
                aria-label={`Remove ${file.name}`}
              >
                <X className="size-3.5" aria-hidden="true" />
              </Button>
            </li>
          ))}
        </ul>
      ) : null}
    </div>
  )
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

A long list of legal and banking fields becomes three short tasks when grouped into panels. Panels are the right tool precisely because the groups are genuinely different in kind.

  • Never ask for real banking details in a demo — the copy says so explicitly.
  • Registration-number format varies by country, so the rule is deliberately permissive within a shape.
  • Panels carry h4 headings so the page outline stays correct inside a form.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Entity panel
  • Contact panel
  • Payment panel
  • IBAN validation

Accessibility

Panel headings
Each group is a labelled region with a real heading.
Monospace identifiers
Reference numbers use a monospace face so digits are distinguishable.
Upload labelling
The document field is a labelled Field wrapping the upload primitive.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.

All forms