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).
'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>
)
}'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
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.
'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-пример для этого поля появится позже.