@letar/forms

Специализированные поля

Phone, PinInput, ColorPicker, FileUpload, Address

Полный пример

Полный sandbox-пример, прочитанный напрямую из исходников form-develop-app / form-develop-app-shadcn на сборке — переключите скин, чтобы увидеть то же поле в разных реализациях. Оба источника показывают FileUpload (shadcn-источник заодно показывает ColorPicker, а также Editable и Signature, которые не входят в эту страницу).

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}
              placeholder="Select your resume..."
            />
          </Box>

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

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

Phone

API: Form.Field.Phone

Ввод номера телефона с маской формата.

const Schema = z.object({
  phone: z.string().meta({
    ui: { title: 'Телефон' },
  }),
})

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

PinInput

API: Form.Field.PinInput

Ввод PIN-кода или OTP.

const Schema = z.object({
  code: z.string().meta({
    ui: { title: 'Код подтверждения' },
  }),
})

<Form.Field.PinInput name="code" />

ColorPicker

API: Form.Field.ColorPicker

Выбор цвета.

const Schema = z.object({
  color: z.string().meta({
    ui: { title: 'Цвет бренда' },
  }),
})

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

FileUpload

API: Form.Field.FileUpload

Загрузка файлов — кнопка или зона перетаскивания.

const Schema = z.object({
  avatar: z.any().optional().meta({
    ui: { title: 'Аватар' },
  }),
})

<Form.Field.FileUpload name="avatar" accept="image/*" />

// Множественная загрузка
<Form.Field.FileUpload name="documents" maxFiles={5} />

Address

API: Form.Field.Address

Адресное поле с автодополнением.

const Schema = z.object({
  address: z.string().meta({
    ui: { title: 'Адрес' },
  }),
})

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

City

API: Form.Field.City

Автодополнение города.

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

Duration

API: Form.Field.Duration

Ввод длительности — часы, минуты, секунды.

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

Schedule

API: Form.Field.Schedule

Расписание на неделю — выбор рабочих часов для каждого дня.

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

OTPInput

API: Form.Field.OTPInput

Ввод одноразового пароля — оптимизирован для SMS/email кодов.

<Form.Field.OTPInput name="otp" />

EditIntent

API: Form.Field.EditIntent

Явная замена секрета, который сервер никогда не отдаёт клиенту обратно (API key, Client Secret и подобное). View mode показывает только безопасный displayValue (маска вида ************P9x4) и кнопку «Заменить» — реальное значение по сети не ходит. isEdited — пользовательский intent, а не производная от isDirty: старый secret намеренно неизвестен клиенту, сравнивать значения не с чем.

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>

При submit без правки уходит { isEdited: false, value: null } — сервер обязан оставить сохранённое значение как есть. После ввода нового значения уходит { isEdited: true, value: '...' } — сервер заменяет значение и заново валидирует его как T. Для нового секрета (create mode) поле стартует сразу в edit mode: { isEdited: true, value: '' }.

Содержимое по умолчанию чувствительное (sensitive, @default true) и автоматически исключается из useFormPersistence/localStorage, Form.UrlSync и маскировки Form.DebugValues. Отдельно серверная схема обязана отклонять саму UI-маску как «новое» значение — клиентская схема не является 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