@letar/forms

Specialized Fields

Phone, Address, FileUpload, PinInput, ColorPicker, and more

Full example

The complete sandbox example, read directly from the form-develop-app / form-develop-app-shadcn source at build time — switch skins to see the same field across implementations. Both sources demonstrate FileUpload (the shadcn source also shows ColorPicker, alongside Editable and Signature which aren't part of this page).

apps/form-develop-app/src/app/file-upload-demo/page.tsx
'use client'

import { Box, Heading, VStack } from '@chakra-ui/react'
import { Form } from '@letar/forms'
import { useState } from 'react'
import { z } from 'zod/v4'
import { DemoPageLayout, SubmittedDataPreview } from '../_components'

/**
 * Demo schema for FileUpload field
 */
const FileUploadSchema = z.object({
  // Single file (avatar)
  avatar: z
    .array(z.instanceof(File))
    .min(1, 'Avatar is required')
    .max(1, 'Only one file allowed')
    .meta({
      ui: { title: 'Avatar', description: 'Upload your profile picture' },
    }),

  // Multiple images (gallery)
  gallery: z
    .array(z.instanceof(File))
    .max(5, 'Maximum 5 images allowed')
    .meta({
      ui: { title: 'Gallery', description: 'Upload up to 5 images' },
    }),

  // Documents
  documents: z.array(z.instanceof(File)).meta({
    ui: { title: 'Documents' },
  }),

  // Resume (single PDF)
  resume: z
    .array(z.instanceof(File))
    .max(1)
    .meta({
      ui: { title: 'Resume', description: 'Upload your resume (PDF)' },
    }),
})

type FileUploadFormData = z.infer<typeof FileUploadSchema>

const initialValues: FileUploadFormData = {
  avatar: [],
  gallery: [],
  documents: [],
  resume: [],
}

export default function FileUploadDemoPage() {
  const [submittedData, setSubmittedData] = useState<{ [key: string]: string[] } | null>(null)

  const handleSubmit = (data: FileUploadFormData) => {
    // Convert File[] to filenames for display
    setSubmittedData({
      avatar: data.avatar.map((f) => f.name),
      gallery: data.gallery.map((f) => f.name),
      documents: data.documents.map((f) => f.name),
      resume: data.resume.map((f) => f.name),
    })
  }

  return (
    <DemoPageLayout
      title="FileUpload Demo"
      description="Form.Field.FileUpload - File upload with multiple variants"
      maxW="800px"
    >
      <Form initialValue={initialValues} schema={FileUploadSchema} onSubmit={handleSubmit}>
        <VStack gap={8} align="stretch">
          {/* Button variant (default) - single image */}
          <Box>
            <Heading size="sm" mb={2}>
              Button Variant (Single Image)
            </Heading>
            <Form.Field.FileUpload name="avatar" accept="image/*" maxFiles={1} buttonText="Upload avatar" clearable />
          </Box>

          {/* Dropzone variant - multiple images */}
          <Box>
            <Heading size="sm" mb={2}>
              Dropzone Variant (Multiple Images)
            </Heading>
            <Form.Field.FileUpload
              name="gallery"
              variant="dropzone"
              accept="image/*"
              maxFiles={5}
              dropzoneLabel="Drag and drop images here"
              dropzoneDescription="PNG, JPG, WebP up to 5MB each"
              showSize
              clearable
            />
          </Box>

          {/* Dropzone variant - documents */}
          <Box>
            <Heading size="sm" mb={2}>
              Dropzone Variant (Documents)
            </Heading>
            <Form.Field.FileUpload
              name="documents"
              variant="dropzone"
              accept=".pdf,.doc,.docx,.txt"
              maxFiles={10}
              dropzoneLabel="Drop documents here"
              dropzoneDescription="PDF, DOC, DOCX, TXT"
              showSize
              clearable
            />
          </Box>

          {/* Input variant */}
          <Box>
            <Heading size="sm" mb={2}>
              Input Variant
            </Heading>
            <Form.Field.FileUpload name="resume" variant="input" accept=".pdf" maxFiles={1} />
          </Box>

          <Form.Button.Submit>Submit</Form.Button.Submit>
        </VStack>
      </Form>

      <SubmittedDataPreview data={submittedData} title="Submitted Files:" />
    </DemoPageLayout>
  )
}

Phone

API: Form.Field.Phone

Phone number input with format mask.

const Schema = z.object({
  phone: z.string().meta({
    ui: { title: 'Phone Number' },
  }),
})

<Form.Field.Phone name="phone" />

FileUpload

API: Form.Field.FileUpload

File upload with preview support.

const Schema = z.object({
  avatar: z.any().meta({
    ui: { title: 'Profile Photo' },
  }),
})

<Form.Field.FileUpload
  name="avatar"
  accept="image/*"
  maxFileSize={5 * 1024 * 1024}
/>

PinInput

API: Form.Field.PinInput

PIN code input for verification.

const Schema = z.object({
  pin: z.string().length(4).meta({
    ui: { title: 'Enter PIN' },
  }),
})

<Form.Field.PinInput name="pin" count={4} />

OTPInput

API: Form.Field.OTPInput

One-time password input for two-factor authentication.

const Schema = z.object({
  otp: z.string().length(6).meta({
    ui: { title: 'Verification Code' },
  }),
})

<Form.Field.OTPInput name="otp" length={6} />

ColorPicker

API: Form.Field.ColorPicker

Color selection input.

const Schema = z.object({
  brandColor: z.string().meta({
    ui: { title: 'Brand Color' },
  }),
})

<Form.Field.ColorPicker name="brandColor" />

Address

API: Form.Field.Address

Address autocomplete with structured output.

const Schema = z.object({
  address: z.object({
    street: z.string(),
    city: z.string(),
    zip: z.string(),
  }).meta({
    ui: { title: 'Shipping Address' },
  }),
})

<Form.Field.Address name="address" />

City

API: Form.Field.City

City autocomplete — standalone city picker.

<Form.Field.City name="city" />

Duration

API: Form.Field.Duration

Duration input — hours, minutes, seconds.

const Schema = z.object({
  duration: z.number().meta({
    ui: { title: 'Duration' },
  }),
})

<Form.Field.Duration name="duration" />

Schedule

API: Form.Field.Schedule

Weekly schedule picker — select working hours for each day.

<Form.Field.Schedule name="workingHours" />

EditIntent

API: Form.Field.EditIntent

Explicit replacement for a secret the server never sends back to the client (API key, Client Secret, and similar). View mode shows only a safe displayValue (a mask like ************P9x4) and a "Replace" button; the server never round-trips the real value. isEdited is a user intent, not derived from isDirty — the old secret is intentionally unknown to the client, so there is nothing to diff against.

import { editIntentValueSchema, emptyEditIntentValue } from '@letar/forms-core/edit-intent'

const Schema = z.object({
  apiKey: editIntentValueSchema(z.string().min(20)),
}).strip()

<Form initialValue={{ apiKey: emptyEditIntentValue<string>() }} schema={Schema} onSubmit={handleSubmit}>
  <Form.Field.EditIntent name="apiKey" displayValue="************P9x4" emptyValue="">
    <Form.Field.Password name="apiKey.value" autoComplete="new-password" />
  </Form.Field.EditIntent>
</Form>

On submit without touching the field, { isEdited: false, value: null } is sent — the server must leave the stored value untouched. After entering a new value, { isEdited: true, value: '...' } is sent — the server replaces the value and revalidates it as T. For a brand-new secret (create mode), start the field already in edit mode: { isEdited: true, value: '' }.

Content is treated as sensitive by default (sensitive prop, @default true) and is automatically excluded from useFormPersistence/localStorage, Form.UrlSync, and Form.DebugValues masking. The server fixture must separately reject the UI mask itself as a "new" value — the client schema is not a security boundary.

EditIntent — Chakra UI
'use client'

import { Box, Code, Heading, Text, VStack } from '@chakra-ui/react'
import { Form } from '@letar/forms'
import { editIntentValueSchema, emptyEditIntentValue } from '@letar/forms-core/edit-intent'
import { useState } from 'react'
import { z } from 'zod/v4'
import { DemoPageLayout, SubmittedDataPreview } from '../_components'

// Симуляция серверной проверки — на настоящем бэкенде схема должна отдельно отклонять
// UI-маску как «новое» значение: клиентская схема не является security boundary.
const MASK_PATTERN = /^\*+/

const EditKeySchema = z
  .object({
    apiKey: editIntentValueSchema(
      z.string().min(20, 'Минимум 20 символов').refine((value) => !MASK_PATTERN.test(value), {
        message: 'Похоже на маску отображения, не на реальный ключ',
      }),
    ),
  })
  .strip()

const CreateSecretSchema = z
  .object({
    clientSecret: editIntentValueSchema(z.string().min(8, 'Минимум 8 символов')),
  })
  .strip()

export default function EditIntentDemoPage() {
  const [editResult, setEditResult] = useState<unknown>(null)
  const [createResult, setCreateResult] = useState<unknown>(null)

  return (
    <DemoPageLayout
      title="Form.Field.EditIntent"
      description="Явная замена значения без передачи старого клиенту (API key, Client Secret)"
    >
      <VStack gap={4} align="stretch">
        <Heading size="lg">1. Редактирование — ключ уже сохранён на сервере</Heading>
        <Text color="fg.muted">
          Сервер никогда не отдаёт настоящий ключ обратно — только безопасную маску. Клик «Заменить» переводит поле в
          edit mode и создаёт новое значение с нуля; «Оставить текущее» отменяет правку без изменений. При submit без
          правки уходит <Code>{'{ isEdited: false, value: null }'}</Code> — сервер значение не трогает.
        </Text>

        <Form
          initialValue={{ apiKey: emptyEditIntentValue<string>() }}
          schema={EditKeySchema}
          onSubmit={(data) => setEditResult(data)}
        >
          <Form.Field.EditIntent
            name="apiKey"
            displayValue="************P9x4"
            editLabel="Заменить ключ"
            cancelLabel="Оставить текущий"
            emptyValue=""
          >
            <Form.Field.Password name="apiKey.value" autoComplete="new-password" label="Новый ключ" />
          </Form.Field.EditIntent>
          <Box mt={4}>
            <Form.Button.Submit>Сохранить</Form.Button.Submit>
          </Box>
        </Form>

        <SubmittedDataPreview data={editResult} />
      </VStack>

      <VStack gap={4} align="stretch" mt={10}>
        <Heading size="lg">2. Создание — секрета ещё нет</Heading>
        <Text color="fg.muted">
          Create mode стартует сразу с <Code>{"{ isEdited: true, value: '' }"}</Code>{' '}
          — поле сразу открыто для ввода, кнопки «Заменить» нет смысла показывать.
        </Text>

        <Form
          initialValue={{ clientSecret: { isEdited: true, value: '' } }}
          schema={CreateSecretSchema}
          onSubmit={(data) => setCreateResult(data)}
        >
          <Form.Field.EditIntent name="clientSecret" displayValue="—" emptyValue="">
            <Form.Field.Password name="clientSecret.value" autoComplete="new-password" label="Client Secret" />
          </Form.Field.EditIntent>
          <Box mt={4}>
            <Form.Button.Submit>Создать</Form.Button.Submit>
          </Box>
        </Form>

        <SubmittedDataPreview data={createResult} />
      </VStack>
    </DemoPageLayout>
  )
}

On this page