Специализированные поля
Phone, PinInput, ColorPicker, FileUpload, Address
Полный пример
Полный sandbox-пример, прочитанный напрямую из исходников form-develop-app /
form-develop-app-shadcn на сборке — переключите скин, чтобы увидеть то же поле в разных
реализациях. Оба источника показывают FileUpload (shadcn-источник заодно показывает ColorPicker,
а также Editable и Signature, которые не входят в эту страницу).
'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>
)
}'use client'
import { FieldColorPicker, FieldEditable, FieldFileUpload, FieldSignature } from '@letar/forms-shadcn'
import { useState } from 'react'
import { DemoForm, DemoPageLayout, SubmittedDataPreview } from '../_components'
interface SpecializedValues {
bio2: string
brandColor: string
attachments: File[]
signature: string
}
const defaultValues: SpecializedValues = {
bio2: 'Кликните для редактирования',
brandColor: '#4299E1',
attachments: [],
signature: '',
}
export default function SpecializedDemoPage() {
const [submitted, setSubmitted] = useState<SpecializedValues | null>(null)
return (
<DemoPageLayout
title="Специализированные поля"
description="Editable, ColorPicker, FileUpload (dropzone), Signature"
>
<DemoForm<SpecializedValues> defaultValues={defaultValues} onSubmit={setSubmitted}>
<FieldEditable name="bio2" label="Кликабельный текст" />
<FieldColorPicker name="brandColor" label="Цвет бренда" />
<FieldFileUpload
name="attachments"
label="Вложения"
variant="dropzone"
maxFiles={3}
showSize
dropzoneDescription="До 3 файлов"
/>
<FieldSignature name="signature" label="Подпись" width={320} height={120} />
<button
type="submit"
className="bg-primary text-primary-foreground rounded-md px-4 py-2 text-sm font-medium"
>
Отправить
</button>
</DemoForm>
<SubmittedDataPreview data={submitted} />
</DemoPageLayout>
)
}Vue-пример для этого поля появится позже.
Angular-пример для этого поля появится позже.
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.
'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>
)
}shadcn-вариант этого примера ещё не готов.
Vue-пример для этого поля появится позже.
Angular-пример для этого поля появится позже.