Zubeyr··ComponentsformscrmRecord Form
A schema-driven CRM form built on react-hook-form: describe sections and fields as data and get accordion, tab, card or step-by-step layouts, an edit mode and a click-to-edit view mode, 17 field types including people pickers, files, time, month, lists and editable tables with totals, calculated fields, show/hide and required-when conditions, validation rules through one resolver that also takes Zod, Valibot or ArkType, uploads with progress and retry, role permissions, drafts, autosave, a leave warning, translations and dayjs-powered locale dates.
Live demo ↗Installation
Run the shadcn CLI in a project that already has components.json. Using the @tomei tab? Register the namespace once first.
pnpm dlx shadcn@latest add https://tomei.zubeyr.dev/ui/r/record-form.json- →Installs
dayjsfrom npm. - →Installs
react-hook-formfrom npm. - →Installs
react-dropzonefrom npm. - →Pulls in
input. - →Pulls in
date-picker. - →Pulls in
toast. - →Pulls in
popover. - →Pulls in
dialog. - →Files land in
components/dashboard/record-form.
Installing by hand? Add the dependencies, then copy the source code below.
pnpm add dayjs react-hook-form react-dropzoneUsage
'use client'
import 'dayjs/locale/fr'
import { useMemo, useState } from 'react'
import { Building2, ClipboardList, Layers, Package, Paperclip, Wallet } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { cn } from '@/lib/utils'
import { toast, Toaster } from '@/registry/dashboard/components/toast/toast'
import { Button } from '@/registry/dashboard/components/button/button'
import {
applyComputed,
ChangeList,
RecordForm,
recordFormResolver,
useFormChanges,
type FormLayout,
type FormSection,
type FormValues,
type Messages,
type OptionObject,
type Row,
type Uploader,
} from '@/components/dashboard/record-form/record-form'
const money = (n: number) => Math.round(n * 100) / 100
const lineTotal = (rows: unknown) =>
(Array.isArray(rows) ? (rows as Row[]) : []).reduce(
(s, r) => s + (Number(r.qty) || 0) * (Number(r.cost) || 0),
0,
)
const COMPANIES = [
'Abay',
'Bole',
'Entoto',
'Gihon',
'Harar',
'Lalibela',
'Rift',
'Sheba',
'Tana',
'Zeway',
]
const KINDS = ['Construction', 'Trading', 'Real Estate', 'Engineering', 'Holdings', 'Logistics']
const CITIES = ['Addis Ababa', 'Adama', 'Bahir Dar', 'Hawassa', 'Mekelle', 'Dire Dawa']
const customers: OptionObject[] = Array.from({ length: 10000 }, (_, i) => ({
value: `cus-${i + 1}`,
label: `${COMPANIES[i % 10]} ${KINDS[Math.floor(i / 10) % 6]} ${Math.floor(i / 60) + 1}`,
description: `${CITIES[i % 6]} · C-${String(i + 1).padStart(5, '0')}`,
avatar: COMPANIES[i % 10][0] + KINDS[Math.floor(i / 10) % 6][0],
}))
const people: OptionObject[] = [
['Hana Solomon', 'Finance officer'],
['Dawit Mekonnen', 'Site supervisor'],
['Selam Girma', 'Architect'],
['Amanuel Girma', 'Site supervisor'],
['Naol Tesfaye', 'Commercial'],
['Yodit Alemayehu', 'Project coordinator'],
].map(([label, description], i) => ({
value: `emp-${i + 1}`,
label,
description,
avatar: label
.split(' ')
.map((w) => w[0])
.join(''),
}))
const addresses = [
'Bole Road, Addis Ababa',
'Bole Medhanialem, Addis Ababa',
'Kazanchis, Addis Ababa',
'Piassa, Addis Ababa',
'CMC Road, Addis Ababa',
'Adama Industrial Park, Adama',
'Kebele 14, Bahir Dar',
'Piazza, Hawassa',
]
const fakeServer = <T,>(result: () => T, ms = 350) =>
new Promise<T>((resolve) => setTimeout(() => resolve(result()), ms))
const searchCustomers = (q: string) =>
fakeServer(() => {
const t = q.trim().toLowerCase()
return customers
.filter(
(c) => !t || c.label.toLowerCase().includes(t) || c.description!.toLowerCase().includes(t),
)
.slice(0, 20)
})
const searchPeople = (q: string) =>
fakeServer(
() => people.filter((p) => p.label.toLowerCase().includes(q.trim().toLowerCase())),
150,
)
const searchAddresses = (q: string) =>
fakeServer(() =>
addresses
.filter((a) => a.toLowerCase().includes(q.trim().toLowerCase()))
.map((a) => ({ value: a, label: a })),
)
const sections: FormSection[] = [
{
id: 'client',
title: 'Client & contract',
icon: <Building2 />,
columns: 3,
span: 'full',
fields: [
{
name: 'customer',
label: 'Customer',
type: 'lookup',
required: true,
search: searchCustomers,
options: [customers[41]],
hint: '10,000 customers, searched on the server',
},
{
name: 'team',
label: 'Project team',
type: 'lookup',
multiple: true,
search: searchPeople,
options: people,
},
{ name: 'phone', label: 'Site phone', type: 'phone', defaultCountry: 'ET' },
{ name: 'site', label: 'Site address', type: 'address', search: searchAddresses, span: 2 },
{ name: 'period', label: 'Contract period', type: 'daterange', required: true },
{ name: 'inspection', label: 'First inspection', type: 'datetime', step: 30 },
{
name: 'value',
label: 'Contract value',
type: 'currency',
decimals: 2,
min: 0,
permissions: { edit: ['finance', 'admin'] },
hint: 'Only Finance and Admin can change this',
},
{ name: 'retention', label: 'Retention', type: 'percent', min: 0, max: 20, decimals: 1 },
{
name: 'effort',
label: 'Estimated effort',
type: 'duration',
max: 60 * 24 * 30,
hint: 'Try “2d 4h”, “90m” or “1:30”',
},
{ name: 'scope', label: 'Scope of work', type: 'richtext', maxLength: 2000 },
{
name: 'signedBy',
label: 'Client signature',
type: 'signature',
permissions: { view: ['finance', 'admin'] },
},
],
},
{
id: 'request',
title: 'Request',
icon: <ClipboardList />,
columns: 3,
span: 'full',
defaultOpen: true,
fields: [
{ name: 'title', label: 'Title', type: 'text', required: true, span: 2, maxLength: 80 },
{
name: 'requester',
label: 'Requested by',
type: 'select',
required: true,
options: [
{
value: 'emp-1',
label: 'Hana Solomon',
description: 'Finance',
avatar: 'HS',
color: '#7c3aed',
},
{
value: 'emp-2',
label: 'Dawit Mekonnen',
description: 'Site supervisor',
avatar: 'DM',
color: '#0891b2',
},
{
value: 'emp-3',
label: 'Selam Girma',
description: 'Architect',
avatar: 'SG',
color: '#ea580c',
},
],
},
{ name: 'neededBy', label: 'Needed by', type: 'date', required: true, min: 'today' },
{
name: 'window',
label: 'Delivery from',
type: 'time',
step: 30,
min: '07:00',
max: '17:00',
},
{ name: 'budgetMonth', label: 'Budget month', type: 'month' },
],
},
{
id: 'items',
title: 'Items',
icon: <Package />,
columns: 3,
span: 'full',
fields: [
{
name: 'lines',
label: 'Line items',
type: 'rows',
required: true,
minLength: 1,
addLabel: 'Add item',
totals: ['qty', 'total'],
columns: [
{ name: 'item', label: 'Item', type: 'text', required: true },
{ name: 'qty', label: 'Qty', type: 'number', required: true, min: 1 },
{ name: 'unit', label: 'Unit', type: 'select', options: ['pcs', 'm', 'm²', 'kg', 'bag'] },
{
name: 'cost',
label: 'Unit cost',
type: 'currency',
required: true,
min: 0,
decimals: 2,
},
{
name: 'status',
label: 'Stock',
type: 'select',
options: ['In stock', 'Backorder'],
},
{
name: 'eta',
label: 'Expected',
type: 'date',
min: 'today',
visibleWhen: { field: 'status', is: 'Backorder' },
required: true,
},
{
name: 'total',
label: 'Line total',
type: 'currency',
decimals: 2,
compute: (row) => money((Number(row.qty) || 0) * (Number(row.cost) || 0)).toFixed(2),
},
],
details: [
{
name: 'supplier',
label: 'Supplier',
type: 'lookup',
search: searchCustomers,
options: [customers[41]],
},
{ name: 'notes', label: 'Notes', type: 'textarea' },
{
name: 'deliveries',
label: 'Deliveries',
type: 'rows',
addLabel: 'Add delivery',
totals: ['qty'],
columns: [
{ name: 'date', label: 'Date', type: 'date', required: true },
{ name: 'qty', label: 'Qty', type: 'number', required: true, min: 1 },
{ name: 'received', label: 'Received', type: 'toggle' },
],
},
],
},
{
name: 'subtotal',
label: 'Subtotal',
type: 'currency',
compute: (v) => money(lineTotal(v.lines)).toFixed(2),
},
{
name: 'vat',
label: 'VAT (15%)',
type: 'currency',
compute: (v) => money(lineTotal(v.lines) * 0.15).toFixed(2),
},
{
name: 'total',
label: 'Total',
type: 'currency',
compute: (v) => money(lineTotal(v.lines) * 1.15).toFixed(2),
},
],
},
{
id: 'phases',
title: 'Phases',
description:
'Break the work into phases. Each phase has its own owner, budget and deliverables.',
icon: <Layers />,
span: 'full',
fields: [
{
name: 'phases',
label: 'Phases',
type: 'group',
itemLabel: 'Phase',
titleField: 'name',
minLength: 1,
fields: [
{ name: 'name', label: 'Phase name', type: 'text', required: true },
{ name: 'owner', label: 'Owner', type: 'lookup', search: searchPeople, options: people },
{ name: 'period', label: 'Dates', type: 'daterange' },
{ name: 'budget', label: 'Budget', type: 'currency', decimals: 2 },
{
name: 'deliverables',
label: 'Deliverables',
type: 'rows',
addLabel: 'Add deliverable',
columns: [
{ name: 'title', label: 'Deliverable', type: 'text', required: true },
{ name: 'due', label: 'Due', type: 'date' },
{ name: 'done', label: 'Done', type: 'toggle' },
],
},
],
},
],
},
{
id: 'payment',
title: 'Payment schedule',
description: 'Split the payment into milestones. The shares must add up to 100%.',
icon: <Wallet />,
fields: [
{
name: 'schedule',
label: 'Milestones',
type: 'rows',
addLabel: 'Add milestone',
totals: ['pct'],
sum: { column: 'pct', equals: 100 },
columns: [
{ name: 'name', label: 'Milestone', type: 'text', required: true },
{ name: 'pct', label: 'Share (%)', type: 'number', required: true, min: 1, max: 100 },
{ name: 'due', label: 'Due', type: 'date' },
],
},
],
},
{
id: 'approval',
permissions: { edit: ['admin'] },
title: 'Approval & files',
icon: <Paperclip />,
columns: 2,
fields: [
{
name: 'approval',
label: 'Decision',
type: 'radio',
span: 'full',
options: [
{ value: 'approved', label: 'Approved', description: 'Purchasing can order everything.' },
{
value: 'partial',
label: 'Partially approved',
description: 'Only part is approved. Say which part below.',
},
{ value: 'rejected', label: 'Not approved', description: 'Nothing is ordered.' },
],
},
{
name: 'approvalNote',
label: 'What is approved',
type: 'textarea',
span: 'full',
visibleWhen: { field: 'approval', is: 'partial' },
required: true,
},
{
name: 'checks',
label: 'Checks done',
type: 'multiselect',
display: 'checkboxes',
options: [
'Three quotes compared',
'Within budget',
'Supplier licence valid',
'Specs signed off',
],
},
{ name: 'specs', label: 'Specification notes', type: 'list', maxLength: 6 },
{
name: 'quotes',
label: 'Quotes',
type: 'file',
accept: '.pdf,image/*',
maxSize: 8 * 1024 * 1024,
maxLength: 3,
},
],
},
]
const fakeUpload: Uploader = (file, { onProgress, signal }) =>
new Promise((resolve, reject) => {
let progress = 0
const timer = setInterval(() => {
progress += 8 + Math.random() * 14
onProgress(Math.min(progress, 100))
if (progress < 100) return
clearInterval(timer)
if (/fail/i.test(file.name)) reject(new Error('Server rejected the file'))
else resolve({ url: URL.createObjectURL(file) })
}, 180)
signal.addEventListener('abort', () => {
clearInterval(timer)
reject(new DOMException('Upload cancelled', 'AbortError'))
})
})
const fr: Messages = {
required: '{label} est obligatoire',
email: 'Saisissez une adresse e-mail valide',
minNumber: 'Doit être au moins {min}',
maxNumber: 'Doit être au plus {max}',
minDate: 'Doit être le {min} ou après',
minCount: 'Ajoutez au moins {count} {noun}',
sumEquals: '{label} doit totaliser {equals} (actuellement {total})',
'noun.row_one': 'ligne',
'noun.row_other': 'lignes',
yes: 'Oui',
no: 'Non',
searchFields: 'Rechercher des champs',
needsAttention: 'À vérifier',
calculated: 'Calculé automatiquement',
next: 'Suivant',
back: 'Retour',
review: 'Vérifier',
submit: 'Envoyer',
addRow: 'Ajouter une ligne',
pasteTip: 'Astuce : collez depuis Excel ou Sheets · Entrée sur la dernière ligne en ajoute une',
chooseFiles: 'Choisir des fichiers',
orDropMany: 'ou déposez-les ici',
uploadingPercent: 'Envoi… {progress} %',
uploadError: 'Échec de l’envoi',
retry: 'Réessayer',
today: 'Aujourd’hui',
now: 'Maintenant',
clear: 'Effacer',
pickDate: 'Choisir une date',
pickDates: 'Choisir des dates',
draftFound: 'Vous avez un brouillon non enregistré de {time}.',
restore: 'Restaurer',
discard: 'Ignorer',
autosaving: 'Enregistrement…',
autosavedAt: 'Enregistré à {time}',
undo: 'Annuler',
rowDeleted: 'Ligne {n} supprimée',
}
const schema = z.object({
title: z.string().refine((t) => !/^untitled/i.test(t), 'Give the request a real title'),
})
const layouts: FormLayout[] = ['accordion', 'tabs', 'cards', 'steps']
function Pills<T extends string>({
label,
value,
options,
onChange,
}: {
label: string
value: T
options: readonly T[]
onChange: (value: T) => void
}) {
return (
<div role="radiogroup" aria-label={label} className="flex rounded-lg bg-muted p-0.5">
{options.map((o) => (
<button
key={o}
type="button"
role="radio"
aria-checked={value === o}
onClick={() => onChange(o)}
className={cn(
'h-7 rounded-md px-3 text-sm capitalize transition-colors',
value === o
? 'bg-card font-medium text-foreground shadow-(--shadow-button)'
: 'text-muted-foreground hover:text-foreground',
)}
>
{o}
</button>
))}
</div>
)
}
const defaultValues = applyComputed(sections, {
customer: 'cus-42',
team: ['emp-3'],
phone: '+251 911 234 567',
site: '',
period: '',
inspection: '',
value: '1500000',
retention: '5',
effort: '2160',
scope: '<p>Supply and fix <b>rebar</b> for the ground slab.</p>',
signedBy: '',
title: 'Untitled request',
requester: 'emp-2',
neededBy: '',
window: '08:00',
budgetMonth: '2026-11',
lines: [
{
id: 'a',
item: 'Rebar 12 mm',
qty: '40',
unit: 'pcs',
cost: '620.00',
status: 'In stock',
eta: '',
supplier: 'cus-42',
notes: '',
deliveries: [{ id: 'd1', date: '2026-11-20', qty: '20', received: false }],
},
{
id: 'b',
item: 'Cement',
qty: '25',
unit: 'bag',
cost: '',
status: 'Backorder',
eta: '',
supplier: '',
notes: '',
deliveries: [],
},
],
phases: [
{
id: 'ph1',
name: 'Foundations',
owner: 'emp-2',
period: '',
budget: '450000.00',
deliverables: [{ id: 'dl1', title: 'Excavation sign-off', due: '', done: false }],
},
],
schedule: [
{ id: 'p1', name: 'Advance', pct: '30', due: '' },
{ id: 'p2', name: 'On delivery', pct: '60', due: '' },
],
approval: 'approved',
approvalNote: '',
checks: ['Within budget'],
specs: ['Grade 60 steel'],
quotes: [],
})
export default function Demo() {
const [layout, setLayout] = useState<FormLayout>('accordion')
const [mode, setMode] = useState<'edit' | 'view'>('edit')
const [saved, setSaved] = useState(false)
const [role, setRole] = useState<'viewer' | 'finance' | 'admin'>('admin')
const [lang, setLang] = useState<'en' | 'fr'>('en')
const [autosaveOn, setAutosaveOn] = useState<'off' | 'on'>('off')
const roles = useMemo(() => [role], [role])
const autosave = useMemo(
() =>
autosaveOn === 'on'
? { save: () => new Promise<void>((r) => setTimeout(r, 700)), delay: 1200 }
: undefined,
[autosaveOn],
)
const form = useForm<FormValues>({
defaultValues,
mode: 'onBlur',
resolver: recordFormResolver({
sections,
schema,
roles,
locale: lang,
messages: lang === 'fr' ? fr : undefined,
}),
})
const changes = useFormChanges(form, sections, { roles, locale: lang })
const save = async (values: FormValues) => {
await toast.promise(new Promise((r) => setTimeout(r, 800)), {
loading: 'Saving request…',
success: 'Request saved',
error: 'Couldn’t save the request',
})
form.reset(values)
setSaved(true)
setTimeout(() => setSaved(false), 1500)
}
return (
<div className="flex w-full max-w-4xl flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<Pills label="Layout" value={layout} options={layouts} onChange={setLayout} />
<Pills label="Mode" value={mode} options={['edit', 'view'] as const} onChange={setMode} />
<Pills
label="Role"
value={role}
options={['viewer', 'finance', 'admin'] as const}
onChange={setRole}
/>
<Pills label="Language" value={lang} options={['en', 'fr'] as const} onChange={setLang} />
<Pills
label="Autosave"
value={autosaveOn}
options={['off', 'on'] as const}
onChange={setAutosaveOn}
/>
</div>
<RecordForm
key={layout + mode}
form={form}
sections={sections}
onSubmit={save}
layout={layout}
mode={mode}
sectionColumns={layout === 'cards' ? 2 : 1}
currency="ETB "
submitLabel="Send for approval"
roles={roles}
locale={lang}
messages={lang === 'fr' ? fr : undefined}
upload={fakeUpload}
draftKey="demo-purchase-request"
autosave={autosave}
/>
<ChangeList changes={changes} />
<Toaster />
{layout !== 'steps' && (
<div className="flex justify-end gap-2">
<Button disabled={!form.formState.isDirty} onClick={() => form.reset()}>
Discard
</Button>
<Button
variant="primary"
loading={form.formState.isSubmitting}
onClick={form.handleSubmit(save)}
>
{saved ? 'Saved ✓' : 'Save request'}
</Button>
</div>
)}
</div>
)
}Docs
Built on react-hook-form
Record Form doesn't keep its own state. You own the form with useForm, and RecordForm renders
every field as a react-hook-form Controller. Everything you know from react-hook-form applies:
mode, reset, watch, formState, handleSubmit.
const form = useForm<FormValues>({
defaultValues,
mode: 'onBlur',
resolver: recordFormResolver({ sections }),
})
return (
<form onSubmit={form.handleSubmit(save)}>
<RecordForm form={form} sections={sections} />
<button type="submit">Save</button>
</form>
)Validation
recordFormResolver runs the rules in your sections (required, min, pattern, conditions,
row rules...) and returns them as react-hook-form errors. Pass any Standard Schema (Zod, Valibot,
ArkType) as schema for cross-field checks. Both run, and errors merge by field path.
const schema = z.object({ title: z.string().min(3) })
useForm({ resolver: recordFormResolver({ sections, schema, roles, locale: 'fr', messages: fr }) })Saving
- Your own button:
form.handleSubmit(save). - Steps layout: pass
onSubmit={save}and the last step submits throughhandleSubmit. - After a save, call
form.reset(values)so the form is clean again.
Calculated fields are written back into the values while the user types, and the resolver returns the final computed values to your submit handler.
Changes, drafts and autosave
const changes = useFormChanges(form, sections)
<RecordForm
form={form}
sections={sections}
draftKey="order-1078"
autosave={{ save: (values) => api.save(values), delay: 1500 }}
/>
<ChangeList changes={changes} />draftKey keeps unsaved edits in localStorage and offers to restore them. autosave saves after
a pause and resets the form's defaults to what was saved. The leave warning turns on while the
form is dirty or uploading. Turn it off with warnOnLeave={false}.
Props
| Prop | Type | Default |
|---|---|---|
form | UseFormReturn<FormValues> | required |
sections | FormSection[] | required |
layout | 'accordion' | 'tabs' | 'cards' | 'steps' | 'accordion' |
mode | 'edit' | 'view' | 'edit' |
onSubmit | SubmitHandler<FormValues> | none |
draftKey | string | none |
autosave | { save, delay? } | none |
upload | (file, { onProgress, signal }) => Promise | none |
roles | string[] | none |
locale | string | 'en' |
fieldTypes | Record<string, FieldTypeRenderer> | none |
Source code
'use client'
import { useRef, useState } from 'react'
import {
Controller,
useFormState,
useWatch,
type SubmitHandler,
type UseFormReturn,
} from 'react-hook-form'
import { cn } from '@/lib/utils'
import { Field } from '@/registry/dashboard/components/input/input'
import {
ConfigProvider,
useFormConfig,
useResolvedConfig,
type RecordFormSettings,
} from './context'
import {
DraftBanner,
SaveStatus,
useAutosave,
useDraft,
useLeaveWarning,
type AutosaveOptions,
} from './drafts'
import { FieldControl, type FieldTypeRenderer } from './field-control'
import { flattenErrors } from './resolver'
import {
applyComputed,
formatValue,
isEmptyValue,
isReadOnly,
isRequired,
isVisible,
canEdit,
type FieldDef,
type FieldValue,
type FormSection,
type FormValues,
} from './schema'
export {
applyComputed,
diffValues,
evaluate,
formatValue,
isEmptyValue,
isReadOnly,
isRequired,
isVisible,
normalizeOptions,
optionLabel,
validateField,
validateFields,
validateRecord,
visibleFields,
} from './schema'
export type {
BuiltInFieldType,
Change,
Condition,
CustomFieldType,
FieldDef,
FieldType,
FieldTypeMap,
FieldValue,
FileItem,
FormErrors,
FormSection,
FormValues,
Option,
OptionObject,
Row,
Rule,
} from './schema'
export { FieldControl } from './field-control'
export { ChangeList, useFormChanges } from './changes'
export { FORM_ERROR, flattenErrors, recordFormResolver } from './resolver'
export type { RecordFormResolverOptions, StandardSchema } from './resolver'
export { RecordFormProvider, useFormConfig } from './context'
export type { RecordFormSettings } from './context'
export { createT, en } from './i18n'
export type { Messages, Translate } from './i18n'
export { xhrUploader } from './upload'
export type { Uploader, UploadContext, UploadResult } from './upload'
export { DraftBanner, SaveStatus, useAutosave, useDraft, useLeaveWarning } from './drafts'
export type { AutosaveOptions, AutosaveState, DraftState } from './drafts'
export { canEdit, canView } from './schema'
export type { Permissions } from './schema'
export type { FieldRenderProps, FieldTypeRenderer } from './field-control'
export type FormLayout = 'accordion' | 'tabs' | 'cards' | 'steps'
type RecordFormProps = {
form: UseFormReturn<FormValues>
sections: FormSection[]
onSubmit?: SubmitHandler<FormValues>
layout?: FormLayout
mode?: 'edit' | 'view'
readOnly?: boolean
sectionColumns?: 1 | 2
fieldTypes?: Record<string, FieldTypeRenderer>
searchable?: boolean
currency?: string
review?: boolean
submitLabel?: string
draftKey?: string
autosave?: AutosaveOptions
warnOnLeave?: boolean
className?: string
} & RecordFormSettings
const NO_TYPES: Record<string, FieldTypeRenderer> = {}
const gridCols = {
1: '',
2: '@sm:grid-cols-2',
3: '@sm:grid-cols-2 @xl:grid-cols-3',
4: '@sm:grid-cols-2 @3xl:grid-cols-4',
}
const wide = new Set(['rows', 'group', 'file', 'richtext', 'signature'])
function spanClass(field: FieldDef, columns: number) {
const span = wide.has(field.type) ? (field.span ?? 'full') : field.span
if (!span || span === 1 || columns === 1) return undefined
if (span === 'full' || span >= columns) return 'col-span-full'
if (span === 2) return '@sm:col-span-2'
return '@sm:col-span-full @3xl:col-span-3'
}
const button =
'inline-flex h-8 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-medium transition-colors disabled:opacity-50'
const touches = (key: string, name: string) => key === name || key.startsWith(`${name}.`)
export function RecordForm({
form,
sections,
onSubmit,
layout = 'accordion',
mode = 'edit',
readOnly = false,
sectionColumns = 1,
fieldTypes = NO_TYPES,
searchable = layout !== 'steps',
currency = '$',
review = true,
submitLabel,
draftKey,
autosave: autosaveOptions,
warnOnLeave = true,
className,
locale: localeProp,
messages,
translate,
roles: rolesProp,
upload,
}: RecordFormProps) {
const parent = useFormConfig()
const config = useResolvedConfig(
{ locale: localeProp, messages, translate, roles: rolesProp, upload },
parent,
)
const { t, roles, locale } = config
const [query, setQuery] = useState('')
const [open, setOpen] = useState(
() => new Set(sections.filter((s) => s.defaultOpen).map((s) => s.id)),
)
const [tab, setTab] = useState(sections[0]?.id)
const [step, setStep] = useState(0)
const [reached, setReached] = useState(0)
const [checking, setChecking] = useState(false)
const [editing, setEditing] = useState<{ name: string; before: FieldValue | undefined } | null>(
null,
)
const values = useWatch({ control: form.control }) as FormValues
const formState = useFormState({ control: form.control })
const errors = flattenErrors(formState.errors)
const errorKey = Object.keys(errors).join('|')
const [seenErrors, setSeenErrors] = useState(errorKey)
const focusedFor = useRef<string | null>(null)
const draft = useDraft(form, draftKey)
const autosave = useAutosave(form, autosaveOptions)
const uploading = Object.values(values).some(
(v) =>
Array.isArray(v) &&
v.some(
(item) =>
item && typeof item === 'object' && 'status' in item && item.status === 'uploading',
),
)
useLeaveWarning(warnOnLeave && (formState.isDirty || uploading))
const submit = () => (onSubmit ? form.handleSubmit(onSubmit)() : Promise.resolve())
const q = query.trim().toLowerCase()
const shownSections = sections
.filter((s) => isVisible(s, values, roles))
.map((s) => ({
...s,
fields: s.fields
.filter((f) => isVisible(f, values, roles))
.map((f) => (canEdit(s, roles) ? f : { ...f, readOnly: true })),
}))
.filter((s) => s.fields.length)
if (seenErrors !== errorKey) {
setSeenErrors(errorKey)
const failing = shownSections.filter((s) =>
s.fields.some((f) => Object.keys(errors).some((k) => touches(k, f.name))),
)
if (failing.length) {
if (layout === 'tabs' && !failing.some((s) => s.id === tab)) setTab(failing[0].id)
if (layout === 'accordion') setOpen(new Set([...open, ...failing.map((s) => s.id)]))
if (layout === 'steps') {
const first = shownSections.indexOf(failing[0])
if (first < step) setStep(first)
}
}
}
const searched = shownSections
.map((s) => ({ ...s, fields: s.fields.filter((f) => f.label.toLowerCase().includes(q)) }))
.filter((s) => s.fields.length)
const valueOf = (field: FieldDef) => (field.compute ? field.compute(values) : values[field.name])
const errorIn = (field: FieldDef) =>
Object.keys(errors).some((k) => errors[k] && touches(k, field.name))
const stats = (section: FormSection) => {
const all = shownSections.find((s) => s.id === section.id)?.fields ?? []
const empty = (f: FieldDef) =>
fieldTypes[f.type]?.isEmpty?.(valueOf(f)) ?? isEmptyValue(valueOf(f))
return {
filled: all.filter((f) => !empty(f)).length,
total: all.length,
hasError: all.some(errorIn),
}
}
const syncComputed = () => {
const current = form.getValues()
const next = applyComputed(sections, current)
for (const [name, value] of Object.entries(next))
if (next[name] !== current[name]) form.setValue(name, value, { shouldDirty: true })
}
const hint = (field: FieldDef) => {
const base = field.hint ?? (field.compute ? t('calculated') : undefined)
const counted =
field.maxLength !== undefined && (field.type === 'text' || field.type === 'textarea')
if (!counted) return base
const length = String(values[field.name] ?? '').length
return (
<span className="flex justify-between gap-3">
<span>{base}</span>
<span className={cn('tabular-nums', length > field.maxLength! && 'text-destructive')}>
{length}/{field.maxLength}
</span>
</span>
)
}
const control = (field: FieldDef) => (
<Controller
name={field.name}
control={form.control}
render={({ field: rhf, fieldState }) => {
const id = `field-${field.name}`
const invalid = fieldState.invalid
return (
<div
className="contents"
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) rhf.onBlur()
}}
>
<FieldControl
id={id}
field={field}
value={field.compute ? valueOf(field) : rhf.value}
values={values}
invalid={invalid}
disabled={readOnly || isReadOnly(field, values, roles)}
describedBy={invalid ? `${id}-error` : field.hint ? `${id}-hint` : undefined}
currency={currency}
errors={errors}
fieldTypes={fieldTypes}
onChange={(v) => {
rhf.onChange(v)
syncComputed()
}}
/>
</div>
)
}}
/>
)
const viewField = (field: FieldDef, columns: number) => {
const locked = readOnly || isReadOnly(field, values, roles)
const isEditing = editing?.name === field.name
const rich = wide.has(field.type)
const value = valueOf(field)
const formatted = formatValue(field, value, fieldTypes, { t, locale })
const text =
field.type === 'currency' && formatted !== '—' ? `${currency}${formatted}` : formatted
const error = errors[field.name]
const finish = (cancel: boolean) => {
if (cancel && editing) {
form.setValue(field.name, editing.before, { shouldDirty: true })
syncComputed()
}
focusedFor.current = null
setEditing(null)
}
return (
<div
key={field.name}
className={cn('flex min-w-0 flex-col gap-1', spanClass(field, columns))}
>
<span id={`field-${field.name}-label`} className="text-xs text-muted-foreground">
{field.label}
{isEditing && isRequired(field, values) && <span> *</span>}
</span>
{isEditing ? (
<div
className="flex flex-col gap-2"
ref={(el) => {
if (!el || focusedFor.current === field.name) return
focusedFor.current = field.name
const target = el.querySelector<HTMLElement>(
'input:not([type=file]), textarea, button',
)
target?.focus()
if (target instanceof HTMLInputElement) target.select()
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.stopPropagation()
finish(true)
}
if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') finish(false)
}}
>
{control(field)}
{error && <span className="text-xs text-destructive">{error}</span>}
<div className="flex gap-1.5">
<button
type="button"
onClick={() => finish(false)}
className={cn(button, 'h-7 bg-primary px-2.5 text-xs text-primary-foreground')}
>
{t('done')}
</button>
<button
type="button"
onClick={() => finish(true)}
className={cn(button, 'h-7 px-2.5 text-xs text-muted-foreground hover:bg-muted')}
>
{t('cancel')}
</button>
</div>
</div>
) : rich ? (
<div className="flex flex-col items-start gap-1.5">
{isEmptyValue(value) ? (
<span className="text-sm text-muted-foreground">—</span>
) : (
<div className="w-full">
<FieldControl
id={`field-${field.name}`}
field={field}
value={value}
values={values}
invalid={false}
disabled
currency={currency}
fieldTypes={fieldTypes}
onChange={() => {}}
/>
</div>
)}
{!locked && (
<button
type="button"
onClick={() => setEditing({ name: field.name, before: values[field.name] })}
className="text-xs font-medium text-muted-foreground hover:text-foreground"
>
{t('editField', { label: field.label.toLowerCase() })}
</button>
)}
</div>
) : (
<button
type="button"
disabled={locked}
aria-labelledby={`field-${field.name}-label`}
title={locked ? undefined : t('clickToEdit')}
onClick={() => setEditing({ name: field.name, before: values[field.name] })}
className={cn(
'group -mx-1.5 flex min-h-7 items-center justify-between gap-2 rounded-md px-1.5 text-left text-sm transition-colors',
!locked && 'hover:bg-muted',
error && 'ring-1 ring-destructive',
text === '—' && 'text-muted-foreground',
)}
>
<span className="min-w-0 break-words whitespace-pre-wrap">{text}</span>
{!locked && (
<span
aria-hidden
className="text-xs text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
>
✎
</span>
)}
</button>
)}
{!isEditing && error && <span className="text-xs text-destructive">{error}</span>}
</div>
)
}
const fields = (section: FormSection) => {
const columns = section.columns ?? 1
return (
<div className="@container rounded-xl bg-card p-3 shadow-(--shadow-card)">
<div className={cn('grid gap-4 [&>*]:min-w-0', gridCols[columns])}>
{section.fields.map((field) =>
mode === 'view' ? (
viewField(field, columns)
) : (
<Field
key={field.name}
label={field.label}
htmlFor={`field-${field.name}`}
hint={hint(field)}
error={errors[field.name]}
required={!readOnly && isRequired(field, values)}
className={spanClass(field, columns)}
>
{control(field)}
</Field>
),
)}
</div>
</div>
)
}
const count = (section: FormSection) => {
const s = stats(section)
return (
<span
className={cn(
'text-xs font-normal tabular-nums',
s.hasError ? 'text-destructive' : 'text-muted-foreground',
)}
>
{s.hasError ? t('needsAttention') : `${s.filled}/${s.total}`}
</span>
)
}
const heading = (section: FormSection, extra?: React.ReactNode) => (
<div className="flex items-start gap-2.5 text-[15px] font-medium [&_svg]:mt-0.5 [&_svg]:size-4 [&_svg]:text-muted-foreground">
{section.icon}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<h3>{section.title}</h3>
{section.description && (
<p className="text-sm font-normal text-muted-foreground">{section.description}</p>
)}
</div>
{extra ?? count(section)}
</div>
)
const cards = (list: FormSection[]) => (
<div className={cn('grid gap-6 [&>*]:min-w-0', sectionColumns === 2 && '@3xl:grid-cols-2')}>
{list.map((section) => (
<section
key={section.id}
className={cn(
'flex flex-col gap-3',
sectionColumns === 2 && section.span === 'full' && 'col-span-full',
)}
>
{heading(section)}
{fields(section)}
</section>
))}
</div>
)
const toggle = (id: string) => {
const next = new Set(open)
if (next.has(id)) next.delete(id)
else next.add(id)
setOpen(next)
}
const accordion = (list: FormSection[]) =>
list.map((section) => {
const expanded = q ? true : open.has(section.id)
return (
<section key={section.id} className="border-b py-1 last:border-0">
<button
type="button"
aria-expanded={expanded}
onClick={() => toggle(section.id)}
className="flex h-11 w-full items-center gap-2.5 text-left text-[15px] font-medium text-muted-foreground hover:text-foreground aria-expanded:text-foreground [&_svg]:size-4"
>
{section.icon}
<span className="flex-1">{section.title}</span>
{count(section)}
<span
aria-hidden
className={cn('text-[10px] transition-transform', expanded && 'rotate-180')}
>
▾
</span>
</button>
{expanded && (
<div className="mb-3 flex flex-col gap-3">
{section.description && (
<p className="text-sm text-muted-foreground">{section.description}</p>
)}
{fields(section)}
</div>
)}
</section>
)
})
const tabs = () => {
const current = shownSections.find((s) => s.id === tab) ?? shownSections[0]
if (!current) return null
return (
<div className="flex flex-col gap-4">
<div
role="tablist"
aria-label="Sections"
className="flex gap-1 overflow-x-auto rounded-xl bg-muted p-1"
>
{shownSections.map((section) => (
<button
key={section.id}
type="button"
role="tab"
id={`tab-${section.id}`}
aria-selected={section.id === current.id}
aria-controls={`panel-${section.id}`}
onClick={() => setTab(section.id)}
className={cn(
'flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-3 text-sm font-medium whitespace-nowrap transition-colors [&_svg]:size-3.5',
section.id === current.id
? 'bg-card text-foreground shadow-(--shadow-button)'
: 'text-muted-foreground hover:text-foreground',
)}
>
{section.icon}
{section.title}
{stats(section).hasError && (
<span
aria-label="Needs attention"
className="size-1.5 rounded-full bg-destructive"
/>
)}
</button>
))}
</div>
<div role="tabpanel" id={`panel-${current.id}`} aria-labelledby={`tab-${current.id}`}>
{current.description && (
<p className="mb-3 text-sm text-muted-foreground">{current.description}</p>
)}
{fields(current)}
</div>
</div>
)
}
const steps = () => {
const last = shownSections.length - 1
const reviewing = review && step > last
const current = shownSections[Math.min(step, last)]
if (!current) return null
const labels = [...shownSections.map((s) => s.title), ...(review ? [t('review')] : [])]
const go = (to: number) => {
setStep(to)
setReached(Math.max(reached, to))
}
const check = (section: FormSection) => form.trigger(section.fields.map((f) => f.name))
const next = async () => {
setChecking(true)
const ok = await check(current).finally(() => setChecking(false))
if (!ok) return
if (step < last || review) go(step + 1)
else await submit()
}
const submitAll = async () => {
setChecking(true)
try {
for (let i = 0; i < shownSections.length; i++) {
if (!(await check(shownSections[i]))) return setStep(i)
}
} finally {
setChecking(false)
}
await submit()
}
return (
<div className="flex flex-col gap-5">
<ol className="flex items-center gap-2 overflow-x-auto pb-1">
{labels.map((label, i) => {
const active = i === step
const done = i < step
const hasError = i <= last && stats(shownSections[i]).hasError
return (
<li key={label} className="flex shrink-0 items-center gap-2">
{i > 0 && <span aria-hidden className="h-px w-6 bg-border" />}
<button
type="button"
disabled={i > reached}
aria-current={active ? 'step' : undefined}
onClick={() => setStep(i)}
className="flex items-center gap-2 text-sm disabled:cursor-default"
>
<span
className={cn(
'flex size-6 items-center justify-center rounded-full border text-xs font-medium tabular-nums',
hasError
? 'border-destructive text-destructive'
: active
? 'border-primary bg-primary text-primary-foreground'
: done
? 'border-foreground/20 bg-muted text-foreground'
: 'text-muted-foreground',
)}
>
{done && !hasError ? '✓' : i + 1}
</span>
<span
className={cn(
'whitespace-nowrap',
active ? 'font-medium text-foreground' : 'text-muted-foreground',
)}
>
{label}
</span>
</button>
</li>
)
})}
</ol>
{reviewing ? (
<div className="flex flex-col gap-4">
{shownSections.map((section, i) => (
<section key={section.id} className="rounded-xl bg-card p-4 shadow-(--shadow-card)">
{heading(
section,
<button
type="button"
onClick={() => setStep(i)}
className="text-sm font-medium text-muted-foreground hover:text-foreground"
>
{t('edit')}
</button>,
)}
<dl className={cn('mt-3 grid gap-x-6 gap-y-3', gridCols[section.columns ?? 2])}>
{section.fields.map((f) => (
<div
key={f.name}
className={cn(
'flex min-w-0 flex-col gap-0.5',
spanClass(f, section.columns ?? 2),
)}
>
<dt className="text-xs text-muted-foreground">{f.label}</dt>
<dd className="text-sm break-words">
{formatValue(f, valueOf(f), fieldTypes, { t, locale })}
</dd>
</div>
))}
</dl>
</section>
))}
</div>
) : (
<section className="flex flex-col gap-3">
{heading(current, <span />)}
{fields(current)}
</section>
)}
<div className="flex items-center justify-between gap-2 border-t pt-4">
<button
type="button"
disabled={step === 0}
onClick={() => setStep(step - 1)}
className={cn(button, 'text-muted-foreground hover:bg-muted disabled:invisible')}
>
{t('back')}
</button>
<span className="text-xs text-muted-foreground tabular-nums">
{t('stepOf', { step: step + 1, total: labels.length })}
</span>
<button
type="button"
disabled={formState.isSubmitting || checking}
aria-busy={checking || undefined}
onClick={() => void (reviewing ? submitAll() : next())}
className={cn(button, 'bg-primary text-primary-foreground hover:bg-primary/90')}
>
{reviewing || (!review && step === last)
? formState.isSubmitting
? t('saving')
: (submitLabel ?? t('submit'))
: checking
? t('checking')
: step === last
? t('review')
: t('next')}
</button>
</div>
</div>
)
}
return (
<ConfigProvider value={config}>
<div className={cn('@container flex flex-col', className)}>
{draft && <DraftBanner draft={draft} />}
{autosave && autosave.status !== 'idle' && (
<div className="mb-2 flex justify-end">
<SaveStatus state={autosave} />
</div>
)}
{searchable && (
<div className="mb-3 flex h-8 items-center gap-2 rounded-lg border border-input bg-card px-2.5 focus-within:border-ring">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Escape' && setQuery('')}
placeholder={t('searchFields')}
aria-label={t('searchFields')}
className="h-full min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
{query && (
<button
type="button"
aria-label={t('clearSearch')}
onClick={() => setQuery('')}
className="text-xs text-muted-foreground hover:text-foreground"
>
✕
</button>
)}
</div>
)}
{q
? layout === 'accordion'
? accordion(searched)
: cards(searched)
: layout === 'steps'
? steps()
: layout === 'tabs'
? tabs()
: layout === 'cards'
? cards(shownSections)
: accordion(shownSections)}
{q && !searched.length && (
<p className="py-6 text-center text-sm text-muted-foreground">
{t('noFieldsMatch', { query })}
</p>
)}
</div>
</ConfigProvider>
)
}import { useFormState, useWatch, type UseFormReturn } from 'react-hook-form'
import { cn } from '@/lib/utils'
import { useFormConfig, useResolvedConfig, type RecordFormSettings } from './context'
import {
diffValues,
type Change,
type FieldTypeMap,
type FormSection,
type FormValues,
} from './schema'
export function useFormChanges(
form: UseFormReturn<FormValues>,
sections: FormSection[],
{ fieldTypes, ...settings }: { fieldTypes?: FieldTypeMap } & RecordFormSettings = {},
): Change[] {
const { t, locale, roles } = useResolvedConfig(settings, useFormConfig())
const values = useWatch({ control: form.control }) as FormValues
const { defaultValues = {} } = useFormState({ control: form.control })
return diffValues(sections, defaultValues as FormValues, values, fieldTypes, { t, locale, roles })
}
type ChangeListProps = {
changes: Change[]
title?: React.ReactNode
className?: string
}
export function ChangeList({ changes, title = 'Unsaved changes', className }: ChangeListProps) {
if (!changes.length) return null
return (
<section className={cn('flex flex-col gap-3', className)}>
<h2 className="flex items-center gap-2 text-base font-semibold">
{title}
<span className="rounded-full bg-muted px-2 text-xs font-medium tabular-nums">
{changes.length}
</span>
</h2>
<ul className="divide-y rounded-xl bg-card px-4 shadow-(--shadow-card)">
{changes.map((c) => (
<li
key={c.id}
className="grid grid-cols-[minmax(0,10rem)_1fr] items-baseline gap-4 py-2.5 text-sm"
>
<span className="truncate text-muted-foreground">{c.label}</span>
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2">
<span className="text-muted-foreground line-through">{c.from}</span>
<span aria-hidden className="text-muted-foreground">
→
</span>
<span className="font-medium">{c.to}</span>
</span>
</li>
))}
</ul>
</section>
)
}'use client'
import { createContext, useContext, useMemo } from 'react'
import { createT, defaultT, type Messages, type T, type Translate } from './i18n'
import type { Uploader } from './upload'
export type RecordFormSettings = {
locale?: string
messages?: Messages
translate?: Translate
roles?: string[]
upload?: Uploader
}
export type RecordFormConfig = Omit<RecordFormSettings, 'messages' | 'translate'> & { t: T }
const Config = createContext<RecordFormConfig>({ t: defaultT })
export const useFormConfig = () => useContext(Config)
export function RecordFormProvider({
children,
...settings
}: RecordFormSettings & { children: React.ReactNode }) {
const parent = useContext(Config)
const value = useResolvedConfig(settings, parent)
return <Config value={value}>{children}</Config>
}
export function useResolvedConfig(settings: RecordFormSettings, parent?: RecordFormConfig) {
const { locale, messages, translate, roles, upload } = settings
return useMemo<RecordFormConfig>(() => {
const base = parent ?? { t: defaultT }
return {
locale: locale ?? base.locale,
roles: roles ?? base.roles,
upload: upload ?? base.upload,
t: messages || translate || locale ? createT({ messages, translate, locale }) : base.t,
}
}, [locale, messages, translate, roles, upload, parent])
}
export const ConfigProvider = Config'use client'
import { useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import { useFormConfig } from './context'
import { Popover } from '@/registry/dashboard/components/popover/popover'
import { normalizeOptions, type Option, type OptionObject } from './schema'
export const triggerClass =
'border-input bg-card text-foreground hover:border-ring flex min-h-8 w-full items-center justify-between gap-2 rounded-lg border px-2.5 py-1 text-left text-sm outline-none focus-visible:border-ring disabled:cursor-default disabled:bg-muted/40 disabled:hover:border-input aria-disabled:cursor-default aria-disabled:bg-muted/40 aria-disabled:hover:border-input'
const chevron = (
<span aria-hidden className="text-[10px] text-muted-foreground">
▾
</span>
)
export const optionRow =
'flex min-h-8 w-full items-center gap-2 rounded-lg px-2 py-1 text-left text-sm hover:bg-muted'
function OptionSearch({
value,
onChange,
onEnter,
}: {
value: string
onChange: (value: string) => void
onEnter?: () => void
}) {
const { t } = useFormConfig()
return (
<input
autoFocus
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
onEnter?.()
}
}}
placeholder={t('search')}
aria-label={t('searchOptions')}
className="mb-1 h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none"
/>
)
}
export function Avatar({ option, size = 'sm' }: { option: OptionObject; size?: 'sm' | 'xs' }) {
if (!option.avatar && !option.color) return null
const cls = cn(
'flex shrink-0 items-center justify-center overflow-hidden rounded-full font-medium',
size === 'sm' ? 'size-6 text-[10px]' : 'size-4 text-[8px]',
)
if (option.avatar && /^(https?:|\/|data:)/.test(option.avatar)) {
return <img src={option.avatar} alt="" className={cls} />
}
if (!option.avatar) {
return (
<span
aria-hidden
className="size-2 shrink-0 rounded-full"
style={{ background: option.color }}
/>
)
}
return (
<span
aria-hidden
className={cn(cls, 'bg-muted text-foreground')}
style={option.color ? { background: option.color, color: 'white' } : undefined}
>
{option.avatar}
</span>
)
}
export function OptionView({ option, wrap = false }: { option: OptionObject; wrap?: boolean }) {
return (
<span className="flex min-w-0 flex-1 items-center gap-2">
<Avatar option={option} />
<span className="flex min-w-0 flex-col">
<span className={wrap ? 'break-words' : 'truncate'}>{option.label}</span>
{option.description && (
<span className="text-xs text-muted-foreground">{option.description}</span>
)}
</span>
</span>
)
}
const filterOptions = (options: OptionObject[], query: string) => {
const q = query.toLowerCase()
return options.filter(
(o) => o.label.toLowerCase().includes(q) || o.description?.toLowerCase().includes(q),
)
}
type BaseProps<T> = {
id: string
value: T
disabled?: boolean
invalid?: boolean
describedBy?: string
onChange: (value: T) => void
}
export function SelectField({
id,
value,
options,
placeholder,
disabled,
invalid,
describedBy,
onChange,
}: BaseProps<string> & { options: Option[]; placeholder?: string }) {
const { t } = useFormConfig()
const [query, setQuery] = useState('')
const all = normalizeOptions(options)
const shown = filterOptions(all, query)
const selected = all.find((o) => o.value === value)
return (
<Popover
block
className="w-full"
trigger={({ open, toggle }) => (
<button
id={id}
type="button"
aria-haspopup="listbox"
aria-expanded={open}
data-invalid={invalid || undefined}
aria-describedby={describedBy}
disabled={disabled}
onClick={toggle}
className={cn(triggerClass, 'data-invalid:border-destructive')}
>
{selected ? (
<span className="flex min-w-0 items-center gap-2">
<Avatar option={selected} size="xs" />
<span className="truncate">{selected.label}</span>
</span>
) : (
<span className="truncate text-muted-foreground">
{value || (placeholder ?? t('select'))}
</span>
)}
{!disabled && chevron}
</button>
)}
>
{(close) => (
<>
{all.length > 7 && <OptionSearch value={query} onChange={setQuery} />}
<ul role="listbox" className="max-h-64 overflow-y-auto">
{shown.map((option) => (
<li key={option.value}>
<button
type="button"
role="option"
aria-selected={option.value === value}
onClick={() => {
onChange(option.value === value ? '' : option.value)
setQuery('')
close()
}}
className={optionRow}
>
<OptionView option={option} />
{option.value === value && <span aria-hidden>✓</span>}
</button>
</li>
))}
{!shown.length && (
<li className="px-2 py-1.5 text-sm text-muted-foreground">{t('noMatchesShort')}</li>
)}
</ul>
</>
)}
</Popover>
)
}
export function Check({ on, round = false }: { on: boolean; round?: boolean }) {
return (
<span
aria-hidden
className={cn(
'flex size-4 shrink-0 items-center justify-center border text-[10px] transition-colors',
round ? 'rounded-full' : 'rounded',
on ? 'border-primary bg-primary text-primary-foreground' : 'border-input bg-card',
)}
>
{on && (round ? <span className="size-1.5 rounded-full bg-current" /> : '✓')}
</span>
)
}
export function MultiSelectField({
id,
value,
options,
creatable = false,
display,
placeholder,
disabled,
invalid,
describedBy,
onChange,
}: BaseProps<string[]> & {
options: Option[]
creatable?: boolean
display?: 'checkboxes' | string
placeholder?: string
}) {
const { t } = useFormConfig()
const [query, setQuery] = useState('')
const base = normalizeOptions(options)
const all = [
...base,
...value.filter((v) => !base.some((o) => o.value === v)).map((v) => ({ value: v, label: v })),
]
const shown = filterOptions(all, query)
const toggle = (option: string) =>
onChange(value.includes(option) ? value.filter((v) => v !== option) : [...value, option])
if (display === 'checkboxes') {
return (
<div
id={id}
role="group"
aria-describedby={describedBy}
className="grid grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-x-3 gap-y-1.5"
>
{all.map((option) => {
const on = value.includes(option.value)
return (
<button
key={option.value}
type="button"
role="checkbox"
aria-checked={on}
disabled={disabled}
onClick={() => toggle(option.value)}
className="flex items-start gap-2 rounded-lg py-1 text-left text-sm disabled:cursor-default"
>
<span className="pt-0.5">
<Check on={on} />
</span>
<OptionView option={option} wrap />
</button>
)
})}
</div>
)
}
const create = () => {
const text = query.trim()
if (!creatable || !text || all.some((o) => o.label === text)) return
onChange([...value, text])
setQuery('')
}
return (
<Popover
block
className="w-full"
trigger={({ open, toggle: toggleOpen }) => (
<div
id={id}
role="button"
tabIndex={disabled ? -1 : 0}
aria-haspopup="listbox"
aria-expanded={open}
aria-disabled={disabled || undefined}
data-invalid={invalid || undefined}
aria-describedby={describedBy}
onClick={() => !disabled && toggleOpen()}
onKeyDown={(e) => !disabled && (e.key === 'Enter' || e.key === ' ') && toggleOpen()}
className={cn(triggerClass, 'flex-wrap justify-start data-invalid:border-destructive')}
>
{value.map((v) => {
const option = all.find((o) => o.value === v) ?? { value: v, label: v }
return (
<span
key={v}
className="flex items-center gap-1 rounded-md bg-muted py-0.5 pr-1 pl-1.5 text-xs"
>
<Avatar option={option} size="xs" />
{option.label}
{!disabled && (
<button
type="button"
aria-label={t('removeOption', { label: option.label })}
onClick={(e) => {
e.stopPropagation()
toggle(v)
}}
className="px-0.5 text-muted-foreground hover:text-foreground"
>
✕
</button>
)}
</span>
)
})}
{!value.length && (
<span className="text-muted-foreground">{placeholder ?? t('add')}</span>
)}
{!disabled && <span className="ml-auto">{chevron}</span>}
</div>
)}
>
<OptionSearch value={query} onChange={setQuery} onEnter={create} />
<ul role="listbox" aria-multiselectable className="max-h-64 overflow-y-auto">
{shown.map((option) => (
<li key={option.value}>
<button
type="button"
role="option"
aria-selected={value.includes(option.value)}
onClick={() => toggle(option.value)}
className={optionRow}
>
<Check on={value.includes(option.value)} />
<OptionView option={option} />
</button>
</li>
))}
{creatable && query.trim() && !all.some((o) => o.label === query.trim()) && (
<li>
<button type="button" onClick={create} className={optionRow}>
{t('createOption', { value: query.trim() })}
</button>
</li>
)}
</ul>
</Popover>
)
}
export function ToggleField({
id,
value,
label,
disabled,
describedBy,
onChange,
}: BaseProps<boolean> & { label?: string }) {
return (
<button
id={id}
type="button"
role="switch"
aria-checked={value}
aria-describedby={describedBy}
disabled={disabled}
onClick={() => onChange(!value)}
className="flex min-h-8 items-center gap-2.5 text-sm disabled:cursor-default disabled:opacity-70"
>
<span
className={cn(
'relative h-5 w-9 shrink-0 rounded-full transition-colors',
value ? 'bg-primary' : 'bg-input',
)}
>
<span
className={cn(
'absolute top-0.5 left-0.5 size-4 rounded-full bg-card shadow transition-transform',
value && 'translate-x-4',
)}
/>
</span>
{label}
</button>
)
}
export function RadioField({
id,
value,
options,
display,
disabled,
describedBy,
onChange,
}: BaseProps<string> & { options: Option[]; display?: string }) {
const all = normalizeOptions(options)
const cards = display === 'cards' || (display !== 'segmented' && all.some((o) => o.description))
if (cards) {
return (
<div
id={id}
role="radiogroup"
aria-describedby={describedBy}
className="flex flex-col gap-1.5"
>
{all.map((option) => {
const on = value === option.value
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={on}
disabled={disabled}
onClick={() => onChange(option.value)}
className={cn(
'flex items-start gap-2.5 rounded-lg border p-2.5 text-left text-sm transition-colors disabled:cursor-default',
on ? 'border-foreground bg-muted/50' : 'hover:border-ring',
)}
>
<span className="pt-0.5">
<Check on={on} round />
</span>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="font-medium">{option.label}</span>
{option.description && (
<span className="text-xs text-muted-foreground">{option.description}</span>
)}
</span>
</button>
)
})}
</div>
)
}
return (
<div
id={id}
role="radiogroup"
aria-describedby={describedBy}
className="flex rounded-lg bg-muted p-0.5"
>
{all.map((option) => (
<button
key={option.value}
type="button"
role="radio"
aria-checked={value === option.value}
disabled={disabled}
onClick={() => onChange(option.value)}
className={cn(
'h-7 flex-1 truncate rounded-md px-2 text-sm transition-colors disabled:cursor-default',
value === option.value
? 'bg-card font-medium text-foreground shadow-(--shadow-button)'
: 'text-muted-foreground enabled:hover:text-foreground',
)}
>
{option.label}
</button>
))}
</div>
)
}
export function ListField({
id,
value,
placeholder,
disabled,
describedBy,
onChange,
}: BaseProps<string[]> & { placeholder?: string }) {
const { t } = useFormConfig()
const [draft, setDraft] = useState('')
const inputs = useRef<(HTMLInputElement | null)[]>([])
const add = () => {
const text = draft.trim()
if (!text) return
onChange([...value, text])
setDraft('')
}
return (
<div id={id} aria-describedby={describedBy} className="flex flex-col gap-1.5">
{value.map((item, i) => (
<div key={i} className="flex items-center gap-2">
<span className="w-4 text-right text-xs text-muted-foreground tabular-nums">{i + 1}</span>
<input
ref={(el) => {
inputs.current[i] = el
}}
value={item}
readOnly={disabled}
aria-label={t('itemN', { n: i + 1 })}
onChange={(e) => onChange(value.map((v, j) => (j === i ? e.target.value : v)))}
onKeyDown={(e) => {
if (e.key === 'Backspace' && !item) {
e.preventDefault()
onChange(value.filter((_, j) => j !== i))
inputs.current[i - 1]?.focus()
}
}}
className="h-8 min-w-0 flex-1 rounded-lg border border-input bg-card px-2.5 text-sm outline-none read-only:bg-muted/40 focus:border-ring"
/>
{!disabled && (
<button
type="button"
aria-label={t('removeItemN', { n: i + 1 })}
onClick={() => onChange(value.filter((_, j) => j !== i))}
className="flex size-8 items-center justify-center rounded-lg text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
>
✕
</button>
)}
</div>
))}
{!disabled && (
<div className="flex items-center gap-2">
<span className="w-4 text-right text-xs text-muted-foreground">+</span>
<input
value={draft}
placeholder={placeholder ?? t('listPlaceholder')}
aria-label={t('newItem')}
onChange={(e) => setDraft(e.target.value)}
onBlur={add}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
add()
}
}}
className="h-8 min-w-0 flex-1 rounded-lg border border-dashed border-input bg-transparent px-2.5 text-sm outline-none placeholder:text-muted-foreground focus:border-solid focus:border-ring"
/>
</div>
)}
</div>
)
}import dayjs from 'dayjs'
import customParseFormat from 'dayjs/plugin/customParseFormat'
import localizedFormat from 'dayjs/plugin/localizedFormat'
dayjs.extend(customParseFormat)
dayjs.extend(localizedFormat)
export { dayjs }
const ISO_DATE = 'YYYY-MM-DD'
const ISO_MONTH = 'YYYY-MM'
const ISO_TIME = 'HH:mm'
const ISO_DATETIME = 'YYYY-MM-DDTHH:mm'
const DATE_INPUTS = [
ISO_DATE,
'D/M/YYYY',
'DD/MM/YYYY',
'M/D/YYYY',
'MM/DD/YYYY',
'D.M.YYYY',
'D MMM YYYY',
'D MMMM YYYY',
'MMM D YYYY',
'MMM D, YYYY',
'MMMM D YYYY',
'MMMM D, YYYY',
]
const local = (value: dayjs.ConfigType, format?: string, locale?: string) => {
const d = format ? dayjs(value, format, true) : dayjs(value)
return locale ? d.locale(locale) : d
}
export const todayIso = () => dayjs().format(ISO_DATE)
export const thisMonthIso = () => dayjs().format(ISO_MONTH)
export function formatDate(iso: string, locale?: string) {
const d = local(iso, ISO_DATE, locale)
return d.isValid() ? d.format('ll') : iso
}
export function formatMonth(iso: string, locale?: string) {
const d = local(iso, ISO_MONTH, locale)
return d.isValid() ? d.format('MMM YYYY') : iso
}
export function formatTime(hhmm: string, locale?: string) {
const d = local(hhmm, ISO_TIME, locale)
return d.isValid() ? d.format('LT') : hhmm
}
export function formatDateTime(value: string, locale?: string) {
const d = local(value, ISO_DATETIME, locale)
if (d.isValid()) return d.format('lll')
return formatDate(value.slice(0, 10), locale)
}
export function formatDateRange(value: string, locale?: string) {
const [a, b] = value.split('/')
if (!a) return ''
return b ? `${formatDate(a, locale)} – ${formatDate(b, locale)}` : formatDate(a, locale)
}
export function formatRelative(date: Date, locale?: string) {
const d = local(date, undefined, locale)
const minutes = dayjs().diff(d, 'minute')
if (minutes < 1) return d.format('LT')
if (dayjs().isSame(d, 'day')) return d.format('LT')
return d.format('lll')
}
export function parseDateInput(text: string, locale?: string): string | null {
const t = text.trim()
if (!t) return null
for (const format of DATE_INPUTS) {
const d = locale ? dayjs(t, format, locale, true) : dayjs(t, format, true)
if (d.isValid()) return d.format(ISO_DATE)
}
return null
}
export const isIsoDate = (value: string) => dayjs(value, ISO_DATE, true).isValid()
export const isIsoDateTime = (value: string) => dayjs(value, ISO_DATETIME, true).isValid()'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { AlertCircle, Check, FileClock, Loader2 } from 'lucide-react'
import { useFormState, useWatch, type UseFormReturn } from 'react-hook-form'
import { cn } from '@/lib/utils'
import { useFormConfig } from './context'
import { formatRelative } from './dates'
import type { FormValues } from './schema'
export type DraftState = { savedAt: Date; restore: () => void; discard: () => void }
export type AutosaveState = {
status: 'idle' | 'pending' | 'saving' | 'saved' | 'error'
savedAt?: Date
error?: string
retry: () => void
}
type StoredDraft = { values: FormValues; savedAt: string }
const storageKey = (key: string) => `record-form:draft:${key}`
export function readDraft(key: string): { values: FormValues; savedAt: Date } | null {
try {
const raw = window.localStorage.getItem(storageKey(key))
if (!raw) return null
const parsed = JSON.parse(raw) as StoredDraft
return { values: parsed.values, savedAt: new Date(parsed.savedAt) }
} catch {
return null
}
}
export function writeDraft(key: string, values: FormValues) {
try {
const stored: StoredDraft = { values, savedAt: new Date().toISOString() }
window.localStorage.setItem(storageKey(key), JSON.stringify(stored))
} catch {}
}
export function clearDraft(key: string) {
try {
window.localStorage.removeItem(storageKey(key))
} catch {}
}
export function useLeaveWarning(active: boolean, message?: string) {
const { t } = useFormConfig()
const text = message ?? t('leaveWarning')
useEffect(() => {
if (!active) return
const onBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault()
event.returnValue = text
return text
}
window.addEventListener('beforeunload', onBeforeUnload)
return () => window.removeEventListener('beforeunload', onBeforeUnload)
}, [active, text])
}
export function useDraft(form: UseFormReturn<FormValues>, key?: string): DraftState | null {
const values = useWatch({ control: form.control }) as FormValues
const { isDirty } = useFormState({ control: form.control })
const [found, setFound] = useState<{ values: FormValues; savedAt: Date } | null>(null)
useEffect(() => {
if (!key) return
const draft = readDraft(key)
if (draft && JSON.stringify(draft.values) !== JSON.stringify(form.getValues()))
queueMicrotask(() => setFound(draft))
}, [key, form])
useEffect(() => {
if (!key || found) return
if (!isDirty) return clearDraft(key)
const timer = setTimeout(() => writeDraft(key, values), 600)
return () => clearTimeout(timer)
}, [key, found, isDirty, values])
if (!key || !found) return null
return {
savedAt: found.savedAt,
restore: () => {
form.reset(found.values, { keepDefaultValues: true })
setFound(null)
},
discard: () => {
clearDraft(key)
setFound(null)
},
}
}
export type AutosaveOptions = { save: (values: FormValues) => Promise<void>; delay?: number }
export function useAutosave(
form: UseFormReturn<FormValues>,
options?: AutosaveOptions,
): AutosaveState | undefined {
const values = useWatch({ control: form.control })
const { isDirty } = useFormState({ control: form.control })
const [state, setState] = useState<Omit<AutosaveState, 'retry'>>({ status: 'idle' })
const latest = useRef(options)
useEffect(() => {
latest.current = options
})
const saveNow = useCallback(async () => {
const save = latest.current?.save
if (!save) return
const snapshot = form.getValues()
setState((s) => ({ ...s, status: 'saving' }))
try {
await save(snapshot)
form.reset(snapshot, {
keepValues: true,
keepErrors: true,
keepTouched: true,
keepIsSubmitted: true,
})
setState({ status: 'saved', savedAt: new Date() })
} catch (error) {
setState({ status: 'error', error: error instanceof Error ? error.message : undefined })
}
}, [form])
const enabled = !!options
const delay = options?.delay ?? 1500
useEffect(() => {
if (!enabled || !isDirty) return
queueMicrotask(() => setState((s) => (s.status === 'saving' ? s : { ...s, status: 'pending' })))
const timer = setTimeout(() => void saveNow(), delay)
return () => clearTimeout(timer)
}, [enabled, isDirty, values, delay, saveNow])
return enabled ? { ...state, retry: () => void saveNow() } : undefined
}
export function DraftBanner({ draft, className }: { draft: DraftState; className?: string }) {
const { t, locale } = useFormConfig()
return (
<div
role="status"
className={cn(
'mb-3 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-xl bg-muted px-3 py-2.5 text-sm [&_svg]:size-4',
className,
)}
>
<FileClock className="shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
{t('draftFound', { time: formatRelative(draft.savedAt, locale) })}
</span>
<span className="flex gap-1.5">
<button
type="button"
onClick={draft.discard}
className="h-7 rounded-lg px-2.5 text-xs font-medium text-muted-foreground hover:bg-card hover:text-foreground"
>
{t('discard')}
</button>
<button
type="button"
onClick={draft.restore}
className="h-7 rounded-lg bg-primary px-2.5 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
{t('restore')}
</button>
</span>
</div>
)
}
export function SaveStatus({ state, className }: { state: AutosaveState; className?: string }) {
const { t, locale } = useFormConfig()
if (state.status === 'idle') return null
return (
<span
role="status"
aria-live="polite"
className={cn(
'inline-flex items-center gap-1.5 text-xs [&_svg]:size-3.5',
state.status === 'error' ? 'text-destructive' : 'text-muted-foreground',
className,
)}
>
{state.status === 'saving' && (
<>
<Loader2 className="animate-spin motion-reduce:animate-none" /> {t('autosaving')}
</>
)}
{state.status === 'pending' && t('unsaved')}
{state.status === 'saved' && (
<>
<Check />
{state.savedAt
? t('autosavedAt', { time: formatRelative(state.savedAt, locale) })
: t('autosaved')}
</>
)}
{state.status === 'error' && (
<>
<AlertCircle /> {t('autosaveFailed')}
<button
type="button"
onClick={state.retry}
className="font-medium underline-offset-2 hover:underline"
>
{t('retry')}
</button>
</>
)}
</span>
)
}'use client'
import { useMemo } from 'react'
import { cn } from '@/lib/utils'
import { Input } from '@/registry/dashboard/components/input/input'
import { DatePicker } from '@/registry/dashboard/components/date-picker/date-picker'
import { pickerLabels, type PickerLabels } from '@/registry/dashboard/components/date-picker/locale'
import { DateRangeField } from '@/registry/dashboard/components/date-picker/date-range-field'
import { DateTimePicker } from '@/registry/dashboard/components/date-picker/date-time-picker'
import { MonthPicker } from '@/registry/dashboard/components/date-picker/month-picker'
import { TimePicker } from '@/registry/dashboard/components/date-picker/time-picker'
import { ListField, MultiSelectField, RadioField, SelectField, ToggleField } from './controls'
import { useFormConfig } from './context'
import { FileField } from './files'
import type { T } from './i18n'
import { GroupField } from './group'
import { AddressField, LookupField } from './lookup'
import { DurationField, NumberField } from './number-input'
import { PhoneField } from './phone'
import { RichTextField } from './rich-text'
import { RowsField } from './rows'
import { SignatureField } from './signature'
import type {
CustomFieldType,
FieldDef,
FieldValue,
FileItem,
FormErrors,
FormValues,
} from './schema'
export type FieldRenderProps = {
id: string
field: FieldDef
value: FieldValue | undefined
values: FormValues
invalid: boolean
disabled: boolean
describedBy?: string
onChange: (value: FieldValue) => void
}
export type FieldTypeRenderer = CustomFieldType & {
render: (props: FieldRenderProps) => React.ReactNode
}
export type FieldControlProps = FieldRenderProps & {
path?: string
currency?: string
errors?: FormErrors
fieldTypes?: Record<string, FieldTypeRenderer>
}
const pickerAliases: Partial<Record<keyof PickerLabels, string>> = {
today: 'today',
now: 'now',
thisMonth: 'thisMonth',
clear: 'clear',
clearValue: 'clear',
pickDate: 'pickDate',
pickTime: 'pickTime',
pickMonth: 'pickMonth',
pickDates: 'pickDates',
pickDateTime: 'pickDateTime',
}
function pickerLabelsFrom(t: T): PickerLabels {
return Object.fromEntries(
(Object.keys(pickerLabels) as (keyof PickerLabels)[]).map((key) => {
const own = t(`picker.${key}`)
if (own !== `picker.${key}`) return [key, own]
const alias = pickerAliases[key]
return [key, alias ? t(alias) : pickerLabels[key]]
}),
) as PickerLabels
}
function limit(value: number | string | undefined, type: 'date' | 'time' | 'month') {
if (value === undefined) return undefined
if (value !== 'today') return String(value)
const d = new Date()
const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
return type === 'month' ? ymd.slice(0, 7) : type === 'date' ? ymd : undefined
}
const asText = (value: FieldValue | undefined) =>
typeof value === 'string' ? value : typeof value === 'number' ? String(value) : ''
const asList = (value: FieldValue | undefined) => (Array.isArray(value) ? (value as string[]) : [])
export function FieldControl(props: FieldControlProps) {
const {
id,
field,
value,
invalid,
disabled,
describedBy,
onChange,
currency = '$',
fieldTypes = {},
} = props
const { t, locale } = useFormConfig()
const labels = useMemo(() => pickerLabelsFrom(t), [t])
const custom = fieldTypes[field.type]
if (custom) return custom.render(props)
const text = asText(value)
const common = { id, disabled, invalid, describedBy }
switch (field.type) {
case 'textarea':
return (
<textarea
id={id}
value={text}
readOnly={disabled}
placeholder={field.placeholder}
aria-invalid={invalid}
aria-describedby={describedBy}
onChange={(e) => onChange(e.target.value)}
className="min-h-20 w-full rounded-lg border border-input bg-card px-2.5 py-2 text-sm leading-relaxed outline-none placeholder:text-muted-foreground read-only:bg-muted/40 focus:border-ring aria-invalid:border-destructive"
/>
)
case 'select':
return (
<SelectField
{...common}
value={text}
options={field.options ?? []}
placeholder={field.placeholder}
onChange={onChange}
/>
)
case 'multiselect':
return (
<MultiSelectField
{...common}
value={asList(value)}
options={field.options ?? []}
creatable={field.creatable}
display={field.display}
placeholder={field.placeholder}
onChange={onChange}
/>
)
case 'toggle':
return (
<ToggleField
{...common}
value={value === true}
label={field.placeholder}
onChange={onChange}
/>
)
case 'radio':
return (
<RadioField
{...common}
value={text}
options={field.options ?? []}
display={field.display}
onChange={onChange}
/>
)
case 'date':
return (
<DatePicker
{...common}
value={text}
min={limit(field.min, 'date')}
max={limit(field.max, 'date')}
placeholder={field.placeholder}
clearable={!field.required}
locale={locale}
labels={labels}
onChange={onChange}
/>
)
case 'time':
return (
<TimePicker
{...common}
value={text}
step={field.step}
min={limit(field.min, 'time')}
max={limit(field.max, 'time')}
placeholder={field.placeholder}
clearable={!field.required}
locale={locale}
labels={labels}
onChange={onChange}
/>
)
case 'month':
return (
<MonthPicker
{...common}
value={text}
min={limit(field.min, 'month')}
max={limit(field.max, 'month')}
placeholder={field.placeholder}
clearable={!field.required}
locale={locale}
labels={labels}
onChange={onChange}
/>
)
case 'list':
return (
<ListField
{...common}
value={asList(value)}
placeholder={field.placeholder}
onChange={onChange}
/>
)
case 'file':
return (
<FileField
{...common}
value={Array.isArray(value) ? (value as FileItem[]) : []}
accept={field.accept}
multiple={field.multiple ?? true}
maxSize={field.maxSize}
maxFiles={field.maxLength}
onChange={onChange}
/>
)
case 'rows':
return <RowsField {...props} Control={FieldControl} />
case 'group':
return <GroupField {...props} Control={FieldControl} />
case 'number':
case 'currency':
case 'percent':
return (
<NumberField
{...common}
value={text}
decimals={field.decimals}
placeholder={field.placeholder}
leading={field.type === 'currency' ? currency.trim() : undefined}
trailing={field.type === 'percent' ? '%' : undefined}
onChange={onChange}
/>
)
case 'duration':
return (
<DurationField
{...common}
value={text}
placeholder={field.placeholder}
onChange={onChange}
/>
)
case 'phone':
return (
<PhoneField
{...common}
value={text}
defaultCountry={field.defaultCountry}
placeholder={field.placeholder}
onChange={onChange}
/>
)
case 'lookup':
return (
<LookupField
{...common}
value={field.multiple ? asList(value) : text}
multiple={field.multiple}
search={field.search}
options={field.options}
placeholder={field.placeholder}
onChange={onChange}
/>
)
case 'address':
return (
<AddressField
{...common}
value={text}
search={field.search}
placeholder={field.placeholder}
onChange={onChange}
/>
)
case 'daterange':
return (
<DateRangeField
{...common}
value={text}
min={limit(field.min, 'date')}
max={limit(field.max, 'date')}
placeholder={field.placeholder}
clearable={!field.required}
locale={locale}
labels={labels}
onChange={onChange}
/>
)
case 'datetime':
return (
<DateTimePicker
{...common}
value={text}
step={field.step}
min={field.min === undefined ? undefined : String(field.min)}
max={field.max === undefined ? undefined : String(field.max)}
placeholder={field.placeholder}
clearable={!field.required}
locale={locale}
labels={labels}
onChange={onChange}
/>
)
case 'richtext':
return (
<RichTextField
{...common}
value={text}
placeholder={field.placeholder}
onChange={onChange}
/>
)
case 'signature':
return <SignatureField {...common} value={text} onChange={onChange} />
default:
return (
<Input
id={id}
value={text}
readOnly={disabled}
placeholder={field.placeholder}
aria-invalid={invalid}
aria-describedby={describedBy}
type={field.type === 'email' ? 'email' : 'text'}
inputMode={field.type === 'url' ? 'url' : field.type === 'email' ? 'email' : undefined}
onChange={(e) => onChange(e.target.value)}
className={cn(disabled && 'bg-muted/40')}
/>
)
}
}'use client'
import { useEffect, useRef, useState } from 'react'
import { RotateCw, Upload, X } from 'lucide-react'
import { useDropzone, type Accept, type FileRejection } from 'react-dropzone'
import { cn } from '@/lib/utils'
import { toast } from '@/registry/dashboard/components/toast/toast'
import { useFormConfig } from './context'
import { showSize, type FileItem } from './schema'
type Rejection = { file: File; reason: 'size' | 'type' | 'count' }
function toAccept(accept?: string): Accept | undefined {
if (!accept) return undefined
const result: Accept = {}
const extensions: string[] = []
for (const part of accept.split(',').map((a) => a.trim())) {
if (part.startsWith('.')) extensions.push(part)
else result[part] = []
}
if (extensions.length) result['application/octet-stream'] = extensions
return result
}
const reasonOf = (r: FileRejection): Rejection['reason'] => {
const code = r.errors[0]?.code
return code === 'file-too-large' ? 'size' : code === 'too-many-files' ? 'count' : 'type'
}
const newId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
export function FileField({
id,
value,
accept,
multiple = true,
maxSize,
maxFiles,
disabled,
invalid,
describedBy,
onChange,
}: {
id: string
value: FileItem[]
accept?: string
multiple?: boolean
maxSize?: number
maxFiles?: number
disabled?: boolean
invalid?: boolean
describedBy?: string
onChange: (value: FileItem[]) => void
}) {
const { upload, t } = useFormConfig()
const current = useRef(value)
const sources = useRef(new Map<string, File>())
const controllers = useRef(new Map<string, AbortController>())
const [retryable, setRetryable] = useState<string[]>([])
const [rejected, setRejected] = useState<string[]>([])
useEffect(() => {
current.current = value
}, [value])
useEffect(() => {
const running = controllers.current
return () => running.forEach((c) => c.abort())
}, [])
const commit = (next: FileItem[]) => {
current.current = next
onChange(next)
}
const patch = (fileId: string, change: Partial<FileItem>) =>
commit(current.current.map((f) => (f.id === fileId ? { ...f, ...change } : f)))
const start = (item: FileItem, file: File) => {
if (!upload) return
const controller = new AbortController()
controllers.current.set(item.id, controller)
let last = 0
upload(file, {
signal: controller.signal,
onProgress: (p) => {
const progress = Math.max(0, Math.min(99, Math.round(p)))
if (progress - last < 2) return
last = progress
patch(item.id, { progress })
},
}).then(
(result) => {
controllers.current.delete(item.id)
sources.current.delete(item.id)
setRetryable((r) => r.filter((x) => x !== item.id))
patch(item.id, { status: 'done', progress: 100, url: result.url, error: undefined })
},
(error: unknown) => {
controllers.current.delete(item.id)
if (error instanceof DOMException && error.name === 'AbortError') return
setRetryable((r) => (r.includes(item.id) ? r : [...r, item.id]))
patch(item.id, {
status: 'error',
error: error instanceof Error ? error.message : t('uploadError'),
})
},
)
}
const add = (files: File[]) => {
setRejected([])
const items = files.map((file) => {
const item: FileItem = {
id: newId(),
name: file.name,
size: file.size,
type: file.type,
url: upload ? undefined : URL.createObjectURL(file),
status: upload ? 'uploading' : 'done',
progress: upload ? 0 : 100,
}
sources.current.set(item.id, file)
return item
})
commit(multiple ? [...current.current, ...items] : items)
items.forEach((item, i) => start(item, files[i]))
}
const reject = (rejections: Rejection[]) => {
const messages = rejections.map((r) =>
r.reason === 'count'
? t('rejectedCount')
: t(r.reason === 'size' ? 'rejectedSize' : 'rejectedType', { name: r.file.name }),
)
const unique = [...new Set(messages)]
setRejected(unique)
if (toast.isMounted()) unique.forEach((m) => toast.error(m))
}
const retry = (item: FileItem) => {
const file = sources.current.get(item.id)
if (!file) return
patch(item.id, { status: 'uploading', progress: 0, error: undefined })
start({ ...item, status: 'uploading' }, file)
}
const remove = (item: FileItem) => {
controllers.current.get(item.id)?.abort()
controllers.current.delete(item.id)
sources.current.delete(item.id)
setRetryable((r) => r.filter((x) => x !== item.id))
commit(current.current.filter((f) => f.id !== item.id))
}
const rules = [
accept &&
accept
.split(',')
.map((a) => {
const k = a.trim().toLowerCase()
if (k.endsWith('/*')) return `${k.slice(0, -2).replace(/^./, (c) => c.toUpperCase())}s`
return (k.includes('/') ? k.split('/')[1] : k.replace(/^\./, '')).toUpperCase()
})
.join(', '),
maxSize && t('upTo', { size: showSize(maxSize) }),
]
.filter(Boolean)
.join(' · ')
const full = maxFiles !== undefined && value.length >= maxFiles
return (
<div className="flex flex-col gap-2">
{!disabled && !full && (
<DropArea
id={id}
accept={accept}
multiple={multiple}
maxSize={maxSize}
maxFiles={maxFiles !== undefined ? Math.max(0, maxFiles - value.length) : undefined}
invalid={invalid}
describedBy={describedBy}
rules={rules}
onFiles={add}
onReject={reject}
/>
)}
{rejected.length > 0 && !toast.isMounted() && (
<ul className="text-xs text-destructive">
{rejected.map((m) => (
<li key={m}>{m}</li>
))}
</ul>
)}
{value.length > 0 && (
<ul className="flex flex-col divide-y rounded-lg border">
{value.map((file) => {
const uploading = file.status === 'uploading'
const failed = file.status === 'error'
return (
<li key={file.id} className="flex flex-col gap-1.5 px-2.5 py-2 text-sm">
<div className="flex items-center gap-2.5">
<span className="flex h-8 w-7 shrink-0 items-center justify-center rounded bg-muted font-mono text-[9px] text-muted-foreground uppercase">
{file.name.split('.').pop()?.slice(0, 4)}
</span>
<span className="flex min-w-0 flex-1 flex-col">
{file.url && !uploading ? (
<a
href={file.url}
target="_blank"
rel="noreferrer"
className="truncate hover:underline"
>
{file.name}
</a>
) : (
<span className="truncate">{file.name}</span>
)}
<span
aria-live="polite"
className={cn(
'text-xs',
failed ? 'text-destructive' : 'text-muted-foreground',
)}
>
{failed
? `${t('uploadError')}${file.error ? ` · ${file.error}` : ''}`
: uploading
? t('uploadingPercent', { progress: file.progress ?? 0 })
: showSize(file.size)}
</span>
</span>
{failed && retryable.includes(file.id) && !disabled && (
<button
type="button"
onClick={() => retry(file)}
className="flex h-7 items-center gap-1 rounded-lg px-2 text-xs font-medium hover:bg-muted [&_svg]:size-3"
>
<RotateCw /> {t('retry')}
</button>
)}
{!disabled && (
<button
type="button"
aria-label={uploading ? t('cancelUpload') : t('remove', { name: file.name })}
title={uploading ? t('cancelUpload') : undefined}
onClick={() => remove(file)}
className="flex size-7 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5"
>
<X />
</button>
)}
</div>
{uploading && (
<div
role="progressbar"
aria-label={file.name}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={file.progress ?? 0}
className="h-1 overflow-hidden rounded-full bg-muted"
>
<div
className="h-full rounded-full bg-primary transition-[width] duration-200 motion-reduce:transition-none"
style={{ width: `${file.progress ?? 0}%` }}
/>
</div>
)}
</li>
)
})}
</ul>
)}
{disabled && !value.length && (
<span className="text-sm text-muted-foreground">{t('noFiles')}</span>
)}
</div>
)
}
function DropArea({
id,
accept,
multiple,
maxSize,
maxFiles,
invalid,
describedBy,
rules,
onFiles,
onReject,
}: {
id: string
accept?: string
multiple: boolean
maxSize?: number
maxFiles?: number
invalid?: boolean
describedBy?: string
rules: string
onFiles: (files: File[]) => void
onReject: (rejections: Rejection[]) => void
}) {
const { t } = useFormConfig()
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: toAccept(accept),
multiple,
maxSize,
maxFiles: multiple ? (maxFiles ?? 0) : 1,
onDrop: (accepted, rejections) => {
if (accepted.length) onFiles(accepted)
if (rejections.length)
onReject(rejections.map((r) => ({ file: r.file, reason: reasonOf(r) })))
},
})
return (
<div
{...getRootProps()}
id={id}
role="button"
tabIndex={0}
aria-describedby={describedBy}
data-invalid={invalid || undefined}
className={cn(
'flex cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border border-dashed px-3 py-4 text-center text-sm transition-colors outline-none focus-visible:border-ring data-invalid:border-destructive',
isDragActive ? 'border-ring bg-muted' : 'border-input hover:border-ring',
)}
>
<input {...getInputProps()} />
<Upload className="size-4 text-muted-foreground" />
{isDragActive ? (
<span className="font-medium">{t('dropNow')}</span>
) : (
<span>
<span className="font-medium">{t(multiple ? 'chooseFiles' : 'chooseFile')}</span>{' '}
<span className="text-muted-foreground">{t(multiple ? 'orDropMany' : 'orDropOne')}</span>
</span>
)}
{rules && <span className="text-xs text-muted-foreground">{rules}</span>}
</div>
)
}'use client'
import { useEffect, useRef, useState } from 'react'
import { ChevronDown, Plus } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Field } from '@/registry/dashboard/components/input/input'
import { useFormConfig } from './context'
import { applyAction, ItemMenu, newItem, UndoBar, useUndo, type ItemAction } from './items'
import { hasErrorUnder, type NestedProps } from './rows'
import {
formatValue,
isReadOnly,
isRequired,
isVisible,
scope,
type FieldDef,
type FieldValue,
type Row,
} from './schema'
const gridCols = {
1: '',
2: '@sm:grid-cols-2',
3: '@sm:grid-cols-2 @xl:grid-cols-3',
}
const wideTypes = new Set(['rows', 'group', 'textarea', 'richtext', 'signature', 'file'])
export function GroupField({ Control, ...props }: NestedProps) {
const {
id,
field,
value,
values,
disabled,
describedBy,
onChange,
currency,
errors = {},
fieldTypes,
path = field.name,
} = props
const fields = field.fields ?? []
const items = Array.isArray(value) ? (value as Row[]) : []
const { t, roles, locale } = useFormConfig()
const noun = field.itemLabel ?? t('noun.item_one').replace(/^./, (c) => c.toUpperCase())
const nounPlural = (field.itemLabelPlural ?? `${noun}s`).toLowerCase()
const canAdd = !disabled && (field.maxLength === undefined || items.length < field.maxLength)
const [collapsed, setCollapsed] = useState<string[]>([])
const wrap = useRef<HTMLDivElement>(null)
const pendingFocus = useRef<number | null>(null)
const { undo, offer, dismiss } = useUndo()
useEffect(() => {
const index = pendingFocus.current
if (index === null) return
pendingFocus.current = null
wrap.current
?.querySelector<HTMLElement>(
`[data-item="${index}"] [data-body] :is(input:not([type=file]), textarea, button, [role=combobox])`,
)
?.focus()
}, [value])
const blank = () => newItem(fields)
const setValue = (i: number, name: string, v: FieldValue) =>
onChange(items.map((item, j) => (j === i ? { ...item, [name]: v } : item)))
const act = (i: number, action: ItemAction) => {
if (action === 'delete') {
const before = items
onChange(items.filter((_, j) => j !== i))
return offer(t('itemDeleted', { label: noun, n: i + 1 }), () => onChange(before))
}
const result = applyAction(items, i, action, blank)
pendingFocus.current = result.focus
onChange(result.list)
if (action === 'up' || action === 'down') return
setCollapsed((c) => c.filter((x) => x !== result.list[result.focus].id))
}
const title = (item: Row, i: number) => {
const titleField = fields.find((f) => f.name === field.titleField)
const text = titleField
? formatValue(titleField, item[titleField.name], fieldTypes, { t, locale })
: t('empty')
return text !== t('empty') ? text : `${noun} ${i + 1}`
}
const subtitle = (item: Row) =>
fields
.filter(
(f) => f.name !== field.titleField && !wideTypes.has(f.type) && f.type !== 'signature',
)
.map((f) =>
formatValue(f, f.compute ? f.compute(scope(values, item)) : item[f.name], fieldTypes, {
t,
locale,
}),
)
.filter((text) => text !== t('empty'))
.slice(0, 3)
.join(' · ')
const body = (item: Row, i: number) => {
const ctx = scope(values, item)
const columns = field.groupColumns ?? 2
return (
<div data-body className="@container">
<div className={cn('grid gap-4 [&>*]:min-w-0', gridCols[columns])}>
{fields.map((f: FieldDef) => {
if (!isVisible(f, ctx, roles)) return null
const key = `${path}.${i}.${f.name}`
const error = errors[key]
return (
<Field
key={f.name}
label={f.label}
htmlFor={`field-${key}`}
hint={f.hint ?? (f.compute ? t('calculated') : undefined)}
error={error}
required={!disabled && isRequired(f, ctx)}
className={cn(wideTypes.has(f.type) && 'col-span-full')}
>
<Control
id={`field-${key}`}
path={key}
field={f}
value={f.compute ? f.compute(ctx) : item[f.name]}
values={ctx}
invalid={!!error}
disabled={!!disabled || isReadOnly(f, ctx, roles)}
describedBy={
error ? `field-${key}-error` : f.hint ? `field-${key}-hint` : undefined
}
currency={currency}
errors={errors}
fieldTypes={fieldTypes}
onChange={(v) => setValue(i, f.name, v)}
/>
</Field>
)
})}
</div>
</div>
)
}
return (
<div ref={wrap} id={id} aria-describedby={describedBy} className="flex min-w-0 flex-col gap-2">
{items.map((item, i) => {
const failing = hasErrorUnder(errors, `${path}.${i}`)
const open = !collapsed.includes(item.id) || failing
return (
<section
key={item.id}
data-item={i}
className={cn(
'rounded-xl border bg-card transition-colors',
failing ? 'border-destructive/60' : 'border-border',
)}
onKeyDown={(e) => {
if (!e.altKey || (e.key !== 'ArrowUp' && e.key !== 'ArrowDown')) return
e.preventDefault()
e.stopPropagation()
act(i, e.key === 'ArrowUp' ? 'up' : 'down')
}}
>
<header className="flex items-center gap-2 px-3 py-2">
<button
type="button"
aria-expanded={open}
onClick={() =>
setCollapsed((c) =>
c.includes(item.id) ? c.filter((x) => x !== item.id) : [...c, item.id],
)
}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
>
<ChevronDown
className={cn(
'size-4 shrink-0 text-muted-foreground transition-transform',
!open && '-rotate-90',
)}
/>
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[11px] font-medium tabular-nums">
{i + 1}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{title(item, i)}</span>
{!open && subtitle(item) && (
<span className="block truncate text-xs text-muted-foreground">
{subtitle(item)}
</span>
)}
</span>
{failing && (
<span className="shrink-0 text-xs text-destructive">{t('needsAttention')}</span>
)}
</button>
{!disabled && (
<ItemMenu
label={title(item, i)}
index={i}
count={items.length}
canAdd={canAdd}
onAction={(a) => act(i, a)}
/>
)}
</header>
{open && <div className="border-t px-3 py-3">{body(item, i)}</div>}
</section>
)
})}
{!items.length && (
<div className="rounded-xl border border-dashed px-3 py-6 text-center text-sm text-muted-foreground">
{t('noItems', { label: nounPlural })}
</div>
)}
{undo && (
<UndoBar
message={undo.message}
onUndo={() => {
undo.restore()
dismiss()
}}
onDismiss={dismiss}
/>
)}
{canAdd && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
pendingFocus.current = items.length
onChange([...items, blank()])
}}
className="flex h-8 items-center gap-1.5 rounded-lg border border-dashed px-3 text-sm font-medium text-muted-foreground hover:border-ring hover:text-foreground [&_svg]:size-3.5"
>
<Plus /> {field.addLabel ?? t('addItem', { label: noun.toLowerCase() })}
</button>
{items.length > 1 && (
<button
type="button"
onClick={() => setCollapsed(collapsed.length ? [] : items.map((x) => x.id))}
className="h-8 rounded-lg px-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground"
>
{collapsed.length ? t('expandAll') : t('collapseAll')}
</button>
)}
</div>
)}
</div>
)
}export const en = {
required: '{label} is required',
email: 'Enter a valid email',
url: 'Enter a valid URL',
number: 'Enter a number',
phone: 'Enter a valid phone number',
duration: 'Enter a duration like 1h 30m',
pattern: 'Invalid format',
minNumber: 'Must be at least {min}',
maxNumber: 'Must be at most {max}',
minDate: 'Must be on or after {min}',
maxDate: 'Must be on or before {max}',
minTime: 'Must be at or after {min}',
maxTime: 'Must be at or before {max}',
minChars_one: 'Use at least {count} character',
minChars_other: 'Use at least {count} characters',
maxChars_one: 'Use at most {count} character',
maxChars_other: 'Use at most {count} characters',
minCount: 'Add at least {count} {noun}',
maxCount: 'Use at most {count} {noun}',
maxSize: '{name} is over {max}',
accept: '{name} isn’t an accepted file type',
sumEquals: '{label} must add up to {equals} (now {total})',
sumMin: '{label} must total at least {min}',
sumMax: '{label} must total at most {max}',
rangeIncomplete: 'Pick a start and an end date',
rangeOrder: 'The end date is before the start date',
rangeMin: 'Must start on or after {min}',
rangeMax: 'Must end on or before {max}',
datetime: 'Pick a date and a time',
uploading: 'Wait for the upload to finish',
uploadFailed: 'Some files failed to upload',
'noun.row_one': 'row',
'noun.row_other': 'rows',
'noun.item_one': 'item',
'noun.item_other': 'items',
'noun.option_one': 'option',
'noun.option_other': 'options',
'noun.file_one': 'file',
'noun.file_other': 'files',
yes: 'Yes',
no: 'No',
empty: '—',
signed: 'Signed',
notSigned: 'Not signed',
rows_one: '{count} row',
rows_other: '{count} rows',
searchFields: 'Search fields',
clearSearch: 'Clear search',
noFieldsMatch: 'No fields match “{query}”',
needsAttention: 'Needs attention',
calculated: 'Calculated automatically',
clickToEdit: 'Click to edit',
editField: 'Edit {label}',
done: 'Done',
cancel: 'Cancel',
edit: 'Edit',
next: 'Next',
back: 'Back',
review: 'Review',
submit: 'Submit',
saving: 'Saving…',
checking: 'Checking…',
stepOf: 'Step {step} of {total}',
addRow: 'Add row',
noRows: 'No rows yet',
noRowsHint: 'No rows yet — add one below or paste from a spreadsheet',
pasteTip: 'Tip: paste from Excel or Sheets · Enter on the last row adds a row',
pasted_one: 'Pasted {count} cell',
pasted_other: 'Pasted {count} cells',
rowsAdded_one: 'added {count} row',
rowsAdded_other: 'added {count} rows',
rowLabel: 'Row {n}',
rowDeleted: 'Row {n} deleted',
rowsDeleted: '{count} rows deleted',
itemDeleted: '{label} {n} deleted',
selected: '{count} selected',
selectAll: 'Select all rows',
selectRow: 'Select row {n}',
dragRow: 'Drag row {n} to reorder (or Alt + arrow keys)',
showDetails: 'Show details for row {n}',
hideDetails: 'Hide details for row {n}',
actionsFor: 'Actions for {label}',
insertAbove: 'Insert above',
insertBelow: 'Insert below',
duplicate: 'Duplicate',
moveUp: 'Move up',
moveDown: 'Move down',
delete: 'Delete',
clear: 'Clear',
undo: 'Undo',
dismiss: 'Dismiss',
addItem: 'Add {label}',
noItems: 'No {label} yet',
expandAll: 'Expand all',
collapseAll: 'Collapse all',
chooseFile: 'Choose a file',
chooseFiles: 'Choose files',
orDropOne: 'or drop it here',
orDropMany: 'or drop them here',
dropNow: 'Drop to upload',
upTo: 'up to {size}',
noFiles: 'No files',
uploadingPercent: 'Uploading… {progress}%',
uploadError: 'Upload failed',
retry: 'Retry',
cancelUpload: 'Cancel upload',
remove: 'Remove {name}',
rejectedSize: '{name} is too large',
rejectedType: '{name} isn’t an accepted file type',
rejectedCount: 'Too many files',
searching: 'Searching…',
updating: 'Updating…',
typeToSearch: 'Type to search',
startTyping: 'Start typing to search',
noMatches: 'No matches for “{query}”',
loadFailed: 'Couldn’t load results.',
tryAgain: 'Try again',
today: 'Today',
now: 'Now',
thisMonth: 'This month',
pickDate: 'Pick a date',
pickTime: 'Pick a time',
pickMonth: 'Pick a month',
pickDates: 'Pick dates',
pickDateTime: 'Pick a date and time',
draftFound: 'You have an unsaved draft from {time}.',
restore: 'Restore',
discard: 'Discard',
autosaving: 'Saving…',
autosaved: 'All changes saved',
autosavedAt: 'Saved {time}',
autosaveFailed: 'Couldn’t save',
unsaved: 'Unsaved changes',
leaveWarning: 'You have unsaved changes. Leave anyway?',
search: 'Search',
searchOptions: 'Search options',
select: 'Select',
add: 'Add',
createOption: 'Create “{value}”',
noMatchesShort: 'No matches',
removeOption: 'Remove {label}',
listPlaceholder: 'Add an item and press Enter',
newItem: 'New item',
itemN: 'Item {n}',
removeItemN: 'Remove item {n}',
addressPlaceholder: 'Start typing an address',
phoneNumber: 'Phone number',
searchCountry: 'Search country or code',
searchCountries: 'Search countries',
countryCode: 'Country code, {name} +{dial}',
draw: 'Draw',
type: 'Type',
sign: 'Sign',
signatureMethod: 'Signature method',
typeName: 'Type your full name',
signAbove: 'Sign above the line',
expandSignature: 'Expand signature pad',
expand: 'Expand',
closeWithoutSaving: 'Close without saving',
signaturePad: 'Signature pad. Draw with a mouse, finger or pen.',
typedSignature: 'Typed signature',
bold: 'Bold',
italic: 'Italic',
underline: 'Underline',
bulletList: 'Bulleted list',
numberedList: 'Numbered list',
link: 'Link',
linkAddress: 'Link address',
clearFormatting: 'Clear formatting',
formatting: 'Formatting',
writeSomething: 'Write something…',
} as const
export type MessageKey = keyof typeof en
export type Messages = Partial<Record<MessageKey | (string & {}), string>>
export type Translate = (key: string, vars?: Record<string, unknown>) => string | undefined
export type T = (key: MessageKey | (string & {}), vars?: Record<string, unknown>) => string
const fill = (template: string, vars: Record<string, unknown> = {}) =>
template.replace(/\{(\w+)\}/g, (_, k: string) =>
vars[k] === undefined ? `{${k}}` : String(vars[k]),
)
export function createT(
options: { translate?: Translate; messages?: Messages; locale?: string } = {},
): T {
const plural = new Intl.PluralRules(options.locale ?? 'en')
const lookup = (key: string, vars?: Record<string, unknown>) =>
options.translate?.(key, vars) ?? options.messages?.[key] ?? (en as Record<string, string>)[key]
return (key, vars) => {
if (vars && typeof vars.count === 'number') {
const form = plural.select(vars.count)
const found = lookup(`${key}_${form}`, vars) ?? lookup(`${key}_other`, vars)
if (found) return fill(found, vars)
}
const found = lookup(key, vars)
return found === undefined ? key : fill(found, vars)
}
}
export const defaultT = createT()'use client'
import { useEffect, useRef, useState } from 'react'
import {
ArrowDown,
ArrowUp,
Copy,
CornerDownLeft,
CornerUpLeft,
MoreHorizontal,
Trash2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from '@/registry/dashboard/components/toast/toast'
import { useFormConfig } from './context'
import { optionRow } from './controls'
import { Popover } from '@/registry/dashboard/components/popover/popover'
import { isNested, type FieldDef, type FieldValue, type Row } from './schema'
export const newId = () => Math.random().toString(36).slice(2, 10)
export function newItem(fields: FieldDef[]): Row {
const item: Row = { id: newId() }
for (const f of fields) {
if (f.compute) continue
item[f.name] =
f.type === 'toggle'
? false
: isNested(f) || f.type === 'multiselect' || f.type === 'list' || f.type === 'file'
? []
: ''
}
return item
}
export function cloneItem(item: Row): Row {
const copy = JSON.parse(JSON.stringify(item)) as Row
const reId = (value: FieldValue | undefined): FieldValue | undefined =>
Array.isArray(value)
? ((value as unknown[]).map((v) =>
v && typeof v === 'object' && 'id' in (v as object)
? Object.fromEntries(
Object.entries(v as Row).map(([k, inner]) => [
k,
k === 'id' ? newId() : reId(inner),
]),
)
: v,
) as FieldValue)
: value
return Object.fromEntries(
Object.entries(copy).map(([k, v]) => [k, k === 'id' ? newId() : reId(v)]),
) as Row
}
export function move<T>(list: T[], from: number, to: number) {
if (to < 0 || to >= list.length || from === to) return list
const next = [...list]
const [item] = next.splice(from, 1)
next.splice(to, 0, item)
return next
}
export type ItemAction = 'above' | 'below' | 'duplicate' | 'up' | 'down' | 'delete'
export function ItemMenu({
label,
index,
count,
canAdd,
onAction,
}: {
label: string
index: number
count: number
canAdd: boolean
onAction: (action: ItemAction) => void
}) {
const { t } = useFormConfig()
const items: {
action: ItemAction
text: string
icon: React.ReactNode
hidden?: boolean
danger?: boolean
}[] = [
{ action: 'above', text: t('insertAbove'), icon: <CornerUpLeft />, hidden: !canAdd },
{ action: 'below', text: t('insertBelow'), icon: <CornerDownLeft />, hidden: !canAdd },
{ action: 'duplicate', text: t('duplicate'), icon: <Copy />, hidden: !canAdd },
{ action: 'up', text: t('moveUp'), icon: <ArrowUp />, hidden: index === 0 },
{ action: 'down', text: t('moveDown'), icon: <ArrowDown />, hidden: index === count - 1 },
{ action: 'delete', text: t('delete'), icon: <Trash2 />, danger: true },
]
return (
<div className="shrink-0">
<Popover
align="end"
className="w-44"
trigger={({ open, toggle }) => (
<button
type="button"
aria-label={t('actionsFor', { label })}
aria-haspopup="menu"
aria-expanded={open}
onClick={toggle}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5"
>
<MoreHorizontal />
</button>
)}
>
{(close) => (
<ul role="menu">
{items
.filter((i) => !i.hidden)
.map((i) => (
<li key={i.action}>
<button
type="button"
role="menuitem"
onClick={() => {
close()
onAction(i.action)
}}
className={cn(
optionRow,
'[&_svg]:size-3.5 [&_svg]:text-muted-foreground',
i.danger && 'text-destructive [&_svg]:text-destructive',
)}
>
{i.icon}
{i.text}
</button>
</li>
))}
</ul>
)}
</Popover>
</div>
)
}
export function useUndo() {
const { t } = useFormConfig()
const [undo, setUndo] = useState<{ message: string; restore: () => void } | null>(null)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => () => void (timer.current && clearTimeout(timer.current)), [])
const offer = (message: string, restore: () => void) => {
if (toast.isMounted()) {
toast(message, { duration: 6000, action: { label: t('undo'), onClick: restore } })
return
}
if (timer.current) clearTimeout(timer.current)
setUndo({ message, restore })
timer.current = setTimeout(() => setUndo(null), 6000)
}
const dismiss = () => setUndo(null)
return { undo, offer, dismiss }
}
export function UndoBar({
message,
onUndo,
onDismiss,
}: {
message: string
onUndo: () => void
onDismiss: () => void
}) {
const { t } = useFormConfig()
return (
<div
role="status"
className="flex items-center justify-between gap-3 rounded-lg bg-foreground px-3 py-2 text-sm text-background"
>
<span>{message}</span>
<span className="flex items-center gap-1">
<button
type="button"
onClick={onUndo}
className="rounded px-2 py-0.5 font-medium hover:bg-background/15"
>
{t('undo')}
</button>
<button
type="button"
aria-label={t('dismiss')}
onClick={onDismiss}
className="rounded px-1.5 py-0.5 opacity-70 hover:opacity-100"
>
✕
</button>
</span>
</div>
)
}
export function applyAction(list: Row[], index: number, action: ItemAction, blank: () => Row) {
switch (action) {
case 'above':
return { list: [...list.slice(0, index), blank(), ...list.slice(index)], focus: index }
case 'below':
return {
list: [...list.slice(0, index + 1), blank(), ...list.slice(index + 1)],
focus: index + 1,
}
case 'duplicate':
return {
list: [...list.slice(0, index + 1), cloneItem(list[index]), ...list.slice(index + 1)],
focus: index + 1,
}
case 'up':
return { list: move(list, index, index - 1), focus: index - 1 }
case 'down':
return { list: move(list, index, index + 1), focus: index + 1 }
case 'delete':
return { list: list.filter((_, i) => i !== index), focus: Math.max(0, index - 1) }
}
}'use client'
import { useId, useRef, useState } from 'react'
import { Loader2, MapPin, Search } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Avatar, Check, OptionView, optionRow, triggerClass } from './controls'
import { useFormConfig } from './context'
import { Popover } from '@/registry/dashboard/components/popover/popover'
import { findOption, rememberOptions, type Option, type OptionObject } from './schema'
type Status = 'idle' | 'loading' | 'done' | 'error'
function useSearch(search?: (query: string) => Promise<OptionObject[]>) {
const [items, setItems] = useState<OptionObject[]>([])
const [status, setStatus] = useState<Status>('idle')
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
const latest = useRef(0)
const run = (query: string, delay = 250) => {
if (!search) return
if (timer.current) clearTimeout(timer.current)
const id = ++latest.current
setStatus('loading')
timer.current = setTimeout(() => {
search(query).then(
(found) => {
if (id !== latest.current) return
rememberOptions(found)
setItems(found)
setStatus('done')
},
() => id === latest.current && setStatus('error'),
)
}, delay)
}
return { items, status, run }
}
function Results({
listId,
items,
status,
query,
active,
selected,
multiple,
onPick,
onRetry,
onHover,
}: {
listId: string
items: OptionObject[]
status: Status
query: string
active: number
selected: string[]
multiple?: boolean
onPick: (option: OptionObject) => void
onRetry: () => void
onHover: (index: number) => void
}) {
const { t } = useFormConfig()
if (status === 'error')
return (
<div className="flex items-center justify-between gap-2 px-2 py-2 text-sm text-muted-foreground">
{t('loadFailed')}
<button
type="button"
onClick={onRetry}
className="font-medium text-foreground hover:underline"
>
{t('tryAgain')}
</button>
</div>
)
return (
<ul
id={listId}
role="listbox"
aria-multiselectable={multiple}
className="max-h-64 overflow-y-auto"
>
{status === 'loading' && !items.length && (
<li className="flex items-center gap-2 px-2 py-2 text-sm text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" /> {t('searching')}
</li>
)}
{items.map((option, i) => {
const on = selected.includes(option.value)
return (
<li key={option.value}>
<button
type="button"
role="option"
id={`${listId}-${i}`}
aria-selected={on}
tabIndex={-1}
onMouseEnter={() => onHover(i)}
onClick={() => onPick(option)}
className={cn(optionRow, i === active && 'bg-muted')}
>
{multiple && <Check on={on} />}
<OptionView option={option} />
{!multiple && on && <span aria-hidden>✓</span>}
</button>
</li>
)
})}
{status === 'done' && !items.length && (
<li className="px-2 py-2 text-sm text-muted-foreground">
{query.trim() ? t('noMatches', { query: query.trim() }) : t('startTyping')}
</li>
)}
{status === 'loading' && items.length > 0 && (
<li className="flex items-center gap-2 px-2 pt-1 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" /> {t('updating')}
</li>
)}
</ul>
)
}
export function LookupField({
id,
value,
multiple = false,
search,
options = [],
placeholder,
disabled,
invalid,
describedBy,
onChange,
}: {
id: string
value: string | string[]
multiple?: boolean
search?: (query: string) => Promise<OptionObject[]>
options?: Option[]
placeholder?: string
disabled?: boolean
invalid?: boolean
describedBy?: string
onChange: (value: string | string[]) => void
}) {
const listId = useId()
const { t } = useFormConfig()
const [query, setQuery] = useState('')
const [active, setActive] = useState(0)
const { items, status, run } = useSearch(search)
const selected = Array.isArray(value) ? value : value ? [value] : []
const label = (v: string) => findOption({ options }, v) ?? { value: v, label: v }
const pick = (option: OptionObject, close: () => void) => {
rememberOptions([option])
if (multiple) {
onChange(
selected.includes(option.value)
? selected.filter((v) => v !== option.value)
: [...selected, option.value],
)
} else {
onChange(option.value === value ? '' : option.value)
close()
}
}
return (
<Popover
block
className="w-full min-w-64"
trigger={({ open, toggle }) => (
<div
id={id}
role="combobox"
tabIndex={disabled ? -1 : 0}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
aria-disabled={disabled || undefined}
aria-describedby={describedBy}
data-invalid={invalid || undefined}
onClick={() => {
if (disabled) return
if (!open) {
setQuery('')
setActive(0)
run('', 0)
}
toggle()
}}
onKeyDown={(e) => {
if (disabled || open || !['Enter', ' ', 'ArrowDown'].includes(e.key)) return
e.preventDefault()
setQuery('')
run('', 0)
toggle()
}}
className={cn(
triggerClass,
'cursor-pointer justify-start data-invalid:border-destructive',
multiple && 'flex-wrap',
)}
>
<Search className="size-3.5 shrink-0 text-muted-foreground" />
{selected.length === 0 && (
<span className="truncate text-muted-foreground">{placeholder ?? t('search')}</span>
)}
{!multiple && selected[0] && (
<span className="flex min-w-0 items-center gap-2">
<Avatar option={label(selected[0])} size="xs" />
<span className="truncate">{label(selected[0]).label}</span>
</span>
)}
{multiple &&
selected.map((v) => (
<span
key={v}
className="flex items-center gap-1 rounded-md bg-muted py-0.5 pr-1 pl-1.5 text-xs"
>
<Avatar option={label(v)} size="xs" />
{label(v).label}
{!disabled && (
<button
type="button"
aria-label={t('removeOption', { label: label(v).label })}
onClick={(e) => {
e.stopPropagation()
onChange(selected.filter((s) => s !== v))
}}
className="px-0.5 text-muted-foreground hover:text-foreground"
>
✕
</button>
)}
</span>
))}
</div>
)}
>
{(close) => (
<>
<input
autoFocus
value={query}
placeholder={t('typeToSearch')}
aria-label={t('search')}
aria-controls={listId}
aria-activedescendant={items[active] ? `${listId}-${active}` : undefined}
onChange={(e) => {
setQuery(e.target.value)
setActive(0)
run(e.target.value)
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setActive(Math.min(items.length - 1, active + 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActive(Math.max(0, active - 1))
} else if (e.key === 'Enter' && items[active]) {
e.preventDefault()
pick(items[active], close)
}
}}
className="mb-1 h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus:border-ring"
/>
<Results
listId={listId}
items={items}
status={status}
query={query}
active={active}
selected={selected}
multiple={multiple}
onPick={(o) => pick(o, close)}
onRetry={() => run(query, 0)}
onHover={setActive}
/>
</>
)}
</Popover>
)
}
export function AddressField({
id,
value,
search,
placeholder,
disabled,
invalid,
describedBy,
onChange,
}: {
id: string
value: string
search?: (query: string) => Promise<OptionObject[]>
placeholder?: string
disabled?: boolean
invalid?: boolean
describedBy?: string
onChange: (value: string) => void
}) {
const listId = useId()
const { t } = useFormConfig()
const [open, setOpen] = useState(false)
const [active, setActive] = useState(-1)
const { items, status, run } = useSearch(search)
const show = open && value.trim().length >= 3 && status !== 'idle'
const choose = (option: OptionObject) => {
onChange(option.label)
setOpen(false)
}
return (
<div className="relative w-full">
<div className="flex h-8 w-full min-w-0 items-center gap-2 rounded-lg border border-input bg-card px-2.5 text-sm focus-within:border-ring has-read-only:bg-muted/40 has-aria-invalid:border-destructive">
<MapPin className="size-3.5 shrink-0 text-muted-foreground" />
<input
id={id}
role="combobox"
aria-expanded={show}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={show && items[active] ? `${listId}-${active}` : undefined}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
autoComplete="off"
readOnly={disabled}
value={value}
placeholder={placeholder ?? t('addressPlaceholder')}
onChange={(e) => {
onChange(e.target.value)
setActive(-1)
setOpen(true)
if (e.target.value.trim().length >= 3) run(e.target.value)
}}
onBlur={() => setTimeout(() => setOpen(false), 120)}
onKeyDown={(e) => {
if (!show) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setActive(Math.min(items.length - 1, active + 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActive(Math.max(0, active - 1))
} else if (e.key === 'Enter' && items[active]) {
e.preventDefault()
choose(items[active])
} else if (e.key === 'Escape') setOpen(false)
}}
className="h-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
/>
{status === 'loading' && open && (
<Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
)}
</div>
{show && (
<div className="absolute top-full left-0 z-50 mt-1.5 w-full min-w-64 rounded-xl bg-popover p-1.5 text-popover-foreground shadow-(--shadow-overlay)">
<Results
listId={listId}
items={items}
status={status}
query={value}
active={active}
selected={[]}
onPick={choose}
onRetry={() => run(value, 0)}
onHover={setActive}
/>
</div>
)}
</div>
)
}'use client'
import { useLayoutEffect, useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import { Input } from '@/registry/dashboard/components/input/input'
import { parseDuration, showDuration } from './schema'
const significant = /[\d.-]/
function clean(raw: string, decimals?: number) {
let out = raw.replace(/[^\d.-]/g, '')
const negative = out.startsWith('-')
out = out.replace(/-/g, '')
const [int, ...rest] = out.split('.')
let frac = rest.join('')
if (decimals !== undefined) frac = frac.slice(0, decimals)
const dot = out.includes('.') && decimals !== 0
return `${negative ? '-' : ''}${int.replace(/^0+(?=\d)/, '')}${dot ? `.${frac}` : ''}`
}
function display(value: string) {
if (!value) return ''
const negative = value.startsWith('-')
const [int, frac] = value.replace('-', '').split('.')
const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return `${negative ? '-' : ''}${grouped}${frac !== undefined ? `.${frac}` : ''}`
}
export function NumberField({
id,
value,
decimals,
leading,
trailing,
placeholder,
disabled,
invalid,
describedBy,
onChange,
}: {
id: string
value: string
decimals?: number
leading?: React.ReactNode
trailing?: React.ReactNode
placeholder?: string
disabled?: boolean
invalid?: boolean
describedBy?: string
onChange: (value: string) => void
}) {
const input = useRef<HTMLInputElement>(null)
const [caret, setCaret] = useState<number | null>(null)
const shown = display(value)
useLayoutEffect(() => {
const el = input.current
if (caret === null || !el || document.activeElement !== el) return
let seen = 0
let pos = 0
while (pos < shown.length && seen < caret) {
if (significant.test(shown[pos])) seen++
pos++
}
el.setSelectionRange(pos, pos)
}, [caret, shown])
return (
<Input
ref={input}
id={id}
inputMode="decimal"
value={shown}
readOnly={disabled}
placeholder={placeholder}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
leading={leading}
trailing={trailing}
onChange={(e) => {
const before = e.target.value.slice(0, e.target.selectionStart ?? e.target.value.length)
setCaret([...before].filter((c) => significant.test(c)).length)
onChange(clean(e.target.value, decimals))
}}
onBlur={() => {
if (decimals === undefined || !value || value === '-') return
const n = Number(value)
if (!Number.isNaN(n)) onChange(n.toFixed(decimals))
}}
className={cn('tabular-nums', disabled && 'bg-muted/40')}
/>
)
}
export function DurationField({
id,
value,
placeholder = 'e.g. 1h 30m',
disabled,
invalid,
describedBy,
onChange,
}: {
id: string
value: string
placeholder?: string
disabled?: boolean
invalid?: boolean
describedBy?: string
onChange: (value: string) => void
}) {
const [draft, setDraft] = useState<string | null>(null)
const minutes = value === '' ? null : Number(value)
const pretty = minutes !== null && !Number.isNaN(minutes) ? showDuration(minutes) : value
const preview = draft !== null ? parseDuration(draft) : null
const commit = () => {
if (draft === null) return
const parsed = parseDuration(draft)
onChange(parsed === null ? draft.trim() : String(parsed))
setDraft(null)
}
return (
<Input
id={id}
value={draft ?? pretty}
readOnly={disabled}
placeholder={placeholder}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
onFocus={() => setDraft(pretty)}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === 'Enter' && commit()}
trailing={
draft !== null && draft.trim() ? (
<span className={cn('text-xs', preview === null && 'text-destructive')}>
{preview === null ? 'Not a duration' : `= ${showDuration(preview)}`}
</span>
) : undefined
}
className={cn(disabled && 'bg-muted/40')}
/>
)
}'use client'
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { useFormConfig } from './context'
import { optionRow } from './controls'
import { Popover } from '@/registry/dashboard/components/popover/popover'
export type Country = { code: string; name: string; dial: string }
export const countries: Country[] = [
{ code: 'ET', name: 'Ethiopia', dial: '251' },
{ code: 'US', name: 'United States', dial: '1' },
{ code: 'CA', name: 'Canada', dial: '1' },
{ code: 'GB', name: 'United Kingdom', dial: '44' },
{ code: 'DE', name: 'Germany', dial: '49' },
{ code: 'FR', name: 'France', dial: '33' },
{ code: 'IT', name: 'Italy', dial: '39' },
{ code: 'ES', name: 'Spain', dial: '34' },
{ code: 'NL', name: 'Netherlands', dial: '31' },
{ code: 'SE', name: 'Sweden', dial: '46' },
{ code: 'CH', name: 'Switzerland', dial: '41' },
{ code: 'TR', name: 'Turkey', dial: '90' },
{ code: 'AE', name: 'United Arab Emirates', dial: '971' },
{ code: 'SA', name: 'Saudi Arabia', dial: '966' },
{ code: 'EG', name: 'Egypt', dial: '20' },
{ code: 'KE', name: 'Kenya', dial: '254' },
{ code: 'UG', name: 'Uganda', dial: '256' },
{ code: 'TZ', name: 'Tanzania', dial: '255' },
{ code: 'RW', name: 'Rwanda', dial: '250' },
{ code: 'DJ', name: 'Djibouti', dial: '253' },
{ code: 'SO', name: 'Somalia', dial: '252' },
{ code: 'SD', name: 'Sudan', dial: '249' },
{ code: 'NG', name: 'Nigeria', dial: '234' },
{ code: 'GH', name: 'Ghana', dial: '233' },
{ code: 'ZA', name: 'South Africa', dial: '27' },
{ code: 'IN', name: 'India', dial: '91' },
{ code: 'CN', name: 'China', dial: '86' },
{ code: 'JP', name: 'Japan', dial: '81' },
{ code: 'KR', name: 'South Korea', dial: '82' },
{ code: 'SG', name: 'Singapore', dial: '65' },
{ code: 'AU', name: 'Australia', dial: '61' },
{ code: 'BR', name: 'Brazil', dial: '55' },
{ code: 'MX', name: 'Mexico', dial: '52' },
]
const flag = (code: string) =>
String.fromCodePoint(...[...code.toUpperCase()].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65))
const group = (digits: string) => digits.replace(/(\d{3})(?=\d)/g, '$1 ').trim()
export function splitPhone(value: string, fallback: string) {
const match = /^\+(\d+)\s*(.*)$/.exec(value.trim())
if (!match)
return {
country: countries.find((c) => c.code === fallback) ?? countries[0],
national: value.replace(/\D/g, ''),
}
const digits = match[1] + match[2].replace(/\D/g, '')
const preferred = countries.find((c) => c.code === fallback)
const country =
(preferred && digits.startsWith(preferred.dial) ? preferred : undefined) ??
[...countries]
.sort((a, b) => b.dial.length - a.dial.length)
.find((c) => digits.startsWith(c.dial)) ??
countries[0]
return { country, national: digits.slice(country.dial.length) }
}
export function PhoneField({
id,
value,
defaultCountry = 'US',
placeholder,
disabled,
invalid,
describedBy,
onChange,
}: {
id: string
value: string
defaultCountry?: string
placeholder?: string
disabled?: boolean
invalid?: boolean
describedBy?: string
onChange: (value: string) => void
}) {
const { t } = useFormConfig()
const parsed = splitPhone(value, defaultCountry)
const [chosen, setChosen] = useState<Country | null>(null)
const country = chosen ?? parsed.country
const [query, setQuery] = useState('')
const shown = countries.filter(
(c) =>
c.name.toLowerCase().includes(query.toLowerCase()) ||
c.dial.startsWith(query.replace(/\D/g, '') || '§'),
)
const emit = (c: Country, national: string) =>
onChange(national ? `+${c.dial} ${group(national)}` : '')
return (
<div
className={cn(
'flex h-8 w-full min-w-0 items-center rounded-lg border border-input bg-card text-sm focus-within:border-ring',
invalid && 'border-destructive',
disabled && 'bg-muted/40',
)}
>
<div className="shrink-0">
<Popover
className="w-64"
trigger={({ open, toggle }) => (
<button
type="button"
aria-label={t('countryCode', { name: country.name, dial: country.dial })}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
onClick={toggle}
className="flex h-[30px] items-center gap-1.5 rounded-l-lg border-r border-input px-2 tabular-nums hover:bg-muted disabled:hover:bg-transparent"
>
<span aria-hidden>{flag(country.code)}</span>
<span className="text-muted-foreground">+{country.dial}</span>
</button>
)}
>
{(close) => (
<>
<input
autoFocus
value={query}
placeholder={t('searchCountry')}
aria-label={t('searchCountries')}
onChange={(e) => setQuery(e.target.value)}
className="mb-1 h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-sm outline-none focus:border-ring"
/>
<ul role="listbox" className="max-h-64 overflow-y-auto">
{shown.map((c) => (
<li key={c.code}>
<button
type="button"
role="option"
aria-selected={c.code === country.code}
onClick={() => {
setChosen(c)
emit(c, parsed.national)
setQuery('')
close()
}}
className={optionRow}
>
<span aria-hidden>{flag(c.code)}</span>
<span className="flex-1 truncate">{c.name}</span>
<span className="text-muted-foreground tabular-nums">+{c.dial}</span>
</button>
</li>
))}
</ul>
</>
)}
</Popover>
</div>
<input
id={id}
type="tel"
inputMode="tel"
autoComplete="tel-national"
readOnly={disabled}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
value={group(parsed.national)}
placeholder={placeholder ?? t('phoneNumber')}
onChange={(e) => {
const raw = e.target.value.trim()
if (raw.startsWith('+')) {
const next = splitPhone(raw, country.code)
setChosen(next.country)
return emit(next.country, next.national)
}
emit(country, raw.replace(/\D/g, '').slice(0, 15))
}}
className="h-full min-w-0 flex-1 bg-transparent px-2.5 tabular-nums outline-none placeholder:text-muted-foreground"
/>
</div>
)
}import type { FieldErrors, Resolver } from 'react-hook-form'
import { createT, type Messages, type Translate } from './i18n'
import {
applyComputed,
validateRecord,
type FieldTypeMap,
type FormErrors,
type FormSection,
type FormValues,
} from './schema'
type PathSegment = PropertyKey | { readonly key: PropertyKey }
type StandardIssue = { readonly message: string; readonly path?: ReadonlyArray<PathSegment> }
type StandardResult =
| { readonly value: unknown; readonly issues?: undefined }
| { readonly issues: ReadonlyArray<StandardIssue> }
export type StandardSchema = {
readonly '~standard': {
readonly version: 1
readonly vendor: string
readonly validate: (value: unknown) => StandardResult | Promise<StandardResult>
}
}
export const FORM_ERROR = '_form'
export type RecordFormResolverOptions = {
sections: FormSection[]
schema?: StandardSchema
fieldTypes?: FieldTypeMap
roles?: string[]
locale?: string
messages?: Messages
translate?: Translate
}
function issuesToErrors(issues: ReadonlyArray<StandardIssue>): FormErrors {
const errors: FormErrors = {}
for (const issue of issues) {
const key =
issue.path
?.map((p) => String(typeof p === 'object' && p !== null && 'key' in p ? p.key : p))
.join('.') || FORM_ERROR
errors[key] ??= issue.message
}
return errors
}
function nestErrors(flat: FormErrors): FieldErrors<FormValues> {
const out: Record<string, unknown> = {}
for (const [path, message] of Object.entries(flat)) {
const keys = path.split('.')
let node = out
keys.forEach((key, i) => {
if (i === keys.length - 1) node[key] = { ...(node[key] as object), type: 'validate', message }
else node = (node[key] ??= {}) as Record<string, unknown>
})
}
return out as FieldErrors<FormValues>
}
export function flattenErrors(errors: unknown, prefix = '', out: FormErrors = {}): FormErrors {
if (!errors || typeof errors !== 'object') return out
for (const [key, node] of Object.entries(errors as Record<string, unknown>)) {
if (key === 'ref' || key === 'types' || !node || typeof node !== 'object') continue
const path = prefix ? `${prefix}.${key}` : key
const message = (node as { message?: unknown }).message
if (typeof message === 'string' && message) out[path] = message
flattenErrors(node, path, out)
}
return out
}
export function recordFormResolver({
sections,
schema,
fieldTypes,
roles,
locale,
messages,
translate,
}: RecordFormResolverOptions): Resolver<FormValues> {
const t = createT({ messages, translate, locale })
return async (values) => {
const computed = applyComputed(sections, values)
const result = schema ? await schema['~standard'].validate(computed) : undefined
const errors = {
...validateRecord(sections, computed, fieldTypes, { t, locale, roles }),
...(result?.issues ? issuesToErrors(result.issues) : {}),
}
return Object.keys(errors).length
? { values: {}, errors: nestErrors(errors) }
: { values: computed, errors: {} }
}
}'use client'
import { useEffect, useRef, useState } from 'react'
import { Bold, Italic, Link2, List, ListOrdered, RemoveFormatting, Underline } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useFormConfig } from './context'
import { plainText } from './schema'
const ALLOWED = new Set(['B', 'STRONG', 'I', 'EM', 'U', 'P', 'BR', 'UL', 'OL', 'LI', 'A', 'DIV'])
const DROP = new Set(['SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'TEMPLATE', 'NOSCRIPT'])
const SAFE_URL = /^(https?:|mailto:)/i
export function sanitizeHtml(html: string) {
if (typeof window === 'undefined') return ''
const doc = new DOMParser().parseFromString(`<div>${html}</div>`, 'text/html')
const root = doc.body.firstElementChild as HTMLElement
const clean = (node: Element) => {
for (const child of [...node.children]) {
if (DROP.has(child.tagName)) {
child.remove()
continue
}
clean(child)
if (!ALLOWED.has(child.tagName)) {
child.replaceWith(...child.childNodes)
continue
}
const href = child.getAttribute('href')
for (const attr of [...child.attributes]) child.removeAttribute(attr.name)
if (child.tagName === 'A') {
if (href && SAFE_URL.test(href.trim())) {
child.setAttribute('href', href.trim())
child.setAttribute('target', '_blank')
child.setAttribute('rel', 'noopener noreferrer')
} else child.replaceWith(...child.childNodes)
}
}
}
clean(root)
return root.innerHTML
}
const tools = [
{ command: 'bold', label: 'bold', icon: <Bold />, keys: 'Ctrl+B' },
{ command: 'italic', label: 'italic', icon: <Italic />, keys: 'Ctrl+I' },
{ command: 'underline', label: 'underline', icon: <Underline />, keys: 'Ctrl+U' },
{ command: 'insertUnorderedList', label: 'bulletList', icon: <List /> },
{ command: 'insertOrderedList', label: 'numberedList', icon: <ListOrdered /> },
]
const prose =
'[&_a]:underline [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5 [&_p]:my-1'
export function RichTextField({
id,
value,
placeholder,
disabled,
invalid,
describedBy,
onChange,
}: {
id: string
value: string
placeholder?: string
disabled?: boolean
invalid?: boolean
describedBy?: string
onChange: (value: string) => void
}) {
const { t } = useFormConfig()
const editor = useRef<HTMLDivElement>(null)
const emitted = useRef<string | null>(null)
const saved = useRef<Range | null>(null)
const [active, setActive] = useState<Record<string, boolean>>({})
const [linking, setLinking] = useState(false)
const [url, setUrl] = useState('')
const empty = !plainText(value)
useEffect(() => {
const el = editor.current
if (!el || (value === emitted.current && el.dataset.filled)) return
el.innerHTML = sanitizeHtml(value)
el.dataset.filled = '1'
emitted.current = value
}, [value, disabled])
useEffect(() => {
if (disabled) return
const update = () => {
if (!editor.current?.contains(document.getSelection()?.anchorNode ?? null)) return
setActive(
Object.fromEntries(
tools.map((tool) => [tool.command, document.queryCommandState(tool.command)]),
),
)
}
document.addEventListener('selectionchange', update)
return () => document.removeEventListener('selectionchange', update)
}, [disabled])
const emit = () => {
const html = sanitizeHtml(editor.current?.innerHTML ?? '')
emitted.current = html
onChange(plainText(html) || /<img/i.test(html) ? html : '')
}
const run = (command: string, arg?: string) => {
editor.current?.focus()
document.execCommand(command, false, arg)
emit()
}
const startLink = () => {
const selection = document.getSelection()
saved.current = selection?.rangeCount ? selection.getRangeAt(0).cloneRange() : null
setUrl('https://')
setLinking(true)
}
const applyLink = () => {
const href = url.trim()
setLinking(false)
if (!SAFE_URL.test(href) || !saved.current) return
editor.current?.focus()
const selection = document.getSelection()
selection?.removeAllRanges()
selection?.addRange(saved.current)
run('createLink', href)
}
if (disabled) {
return (
<div
ref={editor}
id={id}
aria-describedby={describedBy}
className={cn(
'min-h-8 rounded-lg bg-muted/40 px-2.5 py-1.5 text-sm leading-relaxed',
prose,
)}
/>
)
}
return (
<div
className={cn(
'rounded-lg border border-input bg-card transition-[border-color] focus-within:border-ring',
invalid && 'border-destructive',
)}
>
<div
role="toolbar"
aria-label={t('formatting')}
className="flex flex-wrap items-center gap-0.5 border-b p-1"
>
{tools.map((tool) => (
<button
key={tool.command}
type="button"
title={tool.keys ? `${t(tool.label)} (${tool.keys})` : t(tool.label)}
aria-label={t(tool.label)}
aria-pressed={!!active[tool.command]}
onMouseDown={(e) => e.preventDefault()}
onClick={() => run(tool.command)}
className={cn(
'flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5',
active[tool.command] && 'bg-muted text-foreground',
)}
>
{tool.icon}
</button>
))}
<span aria-hidden className="mx-1 h-4 w-px bg-border" />
<button
type="button"
title={t('link')}
aria-label={t('link')}
onMouseDown={(e) => e.preventDefault()}
onClick={startLink}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5"
>
<Link2 />
</button>
<button
type="button"
title={t('clearFormatting')}
aria-label={t('clearFormatting')}
onMouseDown={(e) => e.preventDefault()}
onClick={() => run('removeFormat')}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5"
>
<RemoveFormatting />
</button>
{linking && (
<div className="flex min-w-0 flex-1 items-center gap-1">
<input
autoFocus
value={url}
aria-label={t('linkAddress')}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
applyLink()
}
if (e.key === 'Escape') {
e.stopPropagation()
setLinking(false)
}
}}
className="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:border-ring"
/>
<button
type="button"
onClick={applyLink}
className="h-7 rounded-md bg-primary px-2 text-xs font-medium text-primary-foreground"
>
{t('add')}
</button>
</div>
)}
</div>
<div className="relative">
{empty && (
<span
aria-hidden
className="pointer-events-none absolute top-2 left-2.5 text-sm text-muted-foreground"
>
{placeholder ?? t('writeSomething')}
</span>
)}
<div
ref={editor}
id={id}
role="textbox"
aria-multiline
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
contentEditable
suppressContentEditableWarning
onInput={emit}
onPaste={(e) => {
e.preventDefault()
document.execCommand('insertText', false, e.clipboardData.getData('text/plain'))
}}
className={cn('min-h-24 px-2.5 py-2 text-sm leading-relaxed outline-none', prose)}
/>
</div>
</div>
)
}'use client'
import { Fragment, useEffect, useRef, useState } from 'react'
import { ChevronRight, Copy, GripVertical, Plus, Trash2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { parseDate } from '@/registry/dashboard/components/date-picker/date-picker'
import { toInputValue } from '@/registry/dashboard/components/date-range-picker/dates'
import { Field } from '@/registry/dashboard/components/input/input'
import { toast } from '@/registry/dashboard/components/toast/toast'
import { Check } from './controls'
import { useFormConfig } from './context'
import type { FieldControlProps } from './field-control'
import {
applyAction,
cloneItem,
ItemMenu,
move,
newItem,
UndoBar,
useUndo,
type ItemAction,
} from './items'
import {
isReadOnly,
isRequired,
isVisible,
normalizeOptions,
scope,
type FieldDef,
type FieldValue,
type Row,
} from './schema'
export type NestedProps = FieldControlProps & {
Control: React.ComponentType<FieldControlProps>
}
const cellWidth: Record<string, string> = {
date: 'min-w-40',
datetime: 'min-w-52',
daterange: 'min-w-56',
time: 'min-w-32',
month: 'min-w-36',
select: 'min-w-32',
multiselect: 'min-w-40',
lookup: 'min-w-44',
phone: 'min-w-48',
number: 'min-w-20',
currency: 'min-w-28',
percent: 'min-w-24',
duration: 'min-w-28',
toggle: 'min-w-14',
}
const PASTEABLE = new Set([
'text',
'email',
'url',
'phone',
'textarea',
'number',
'currency',
'percent',
'select',
'radio',
'date',
'toggle',
])
function fromPaste(col: FieldDef, raw: string): FieldValue | undefined {
const t = raw.trim()
switch (col.type) {
case 'number':
case 'currency':
case 'percent':
return t.replace(/[^\d.-]/g, '')
case 'toggle':
return /^(y|yes|true|1|x|✓|✔)$/i.test(t)
case 'select':
case 'radio': {
const match = normalizeOptions(col.options).find(
(o) =>
o.value.toLowerCase() === t.toLowerCase() || o.label.toLowerCase() === t.toLowerCase(),
)
return match ? match.value : undefined
}
case 'date': {
const d = parseDate(t)
return d ? toInputValue(d) : undefined
}
default:
return t
}
}
export const hasErrorUnder = (errors: Record<string, string>, prefix: string) =>
Object.keys(errors).some((k) => errors[k] && k.startsWith(`${prefix}.`))
export function RowsField({ Control, ...props }: NestedProps) {
const {
id,
field,
value,
values,
disabled,
describedBy,
onChange,
currency = '$',
errors = {},
fieldTypes,
path = field.name,
} = props
const { t, roles } = useFormConfig()
const columns = field.columns ?? []
const details = field.details ?? []
const all = [...columns, ...details]
const rows = Array.isArray(value) ? (value as Row[]) : []
const canAdd = !disabled && (field.maxLength === undefined || rows.length < field.maxLength)
const [selected, setSelected] = useState<string[]>([])
const [open, setOpen] = useState<string[]>([])
const [compact, setCompact] = useState(false)
const [width, setWidth] = useState(0)
const [drag, setDrag] = useState<{ from: number; over: number } | null>(null)
const [armed, setArmed] = useState<string | null>(null)
const [note, setNote] = useState('')
const wrap = useRef<HTMLDivElement>(null)
const pendingFocus = useRef<number | null>(null)
const noteTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const { undo, offer, dismiss } = useUndo()
useEffect(() => {
const el = wrap.current
if (!el) return
const observer = new ResizeObserver(([entry]) => {
setCompact(entry.contentRect.width < 560)
setWidth(entry.contentRect.width)
})
observer.observe(el)
return () => observer.disconnect()
}, [])
useEffect(() => {
const index = pendingFocus.current
if (index === null) return
pendingFocus.current = null
wrap.current
?.querySelector<HTMLElement>(
`[data-row="${index}"] [data-cell] :is(input:not([type=file]), textarea, button, [role=combobox])`,
)
?.focus()
}, [value])
const ctxOf = (row: Row) => scope(values, row)
const cellValue = (row: Row, col: FieldDef) =>
col.compute ? col.compute(ctxOf(row)) : row[col.name]
const blank = () => newItem(all)
const setCell = (i: number, name: string, v: FieldValue) =>
onChange(rows.map((r, j) => (j === i ? { ...r, [name]: v } : r)))
const flash = (text: string) => {
if (toast.isMounted()) return void toast.success(text)
setNote(text)
if (noteTimer.current) clearTimeout(noteTimer.current)
noteTimer.current = setTimeout(() => setNote(''), 4000)
}
const remove = (ids: string[]) => {
const before = rows
onChange(rows.filter((r) => !ids.includes(r.id)))
setSelected((s) => s.filter((x) => !ids.includes(x)))
offer(
ids.length === 1
? t('rowDeleted', { n: before.findIndex((r) => r.id === ids[0]) + 1 })
: t('rowsDeleted', { count: ids.length }),
() => onChange(before),
)
}
const act = (i: number, action: ItemAction) => {
if (action === 'delete') return remove([rows[i].id])
const result = applyAction(rows, i, action, blank)
pendingFocus.current = result.focus
onChange(result.list)
}
const add = () => {
pendingFocus.current = rows.length
onChange([...rows, blank()])
}
const duplicateSelected = () => {
const next: Row[] = []
for (const r of rows) {
next.push(r)
if (selected.includes(r.id)) next.push(cloneItem(r))
}
onChange(field.maxLength !== undefined ? next.slice(0, field.maxLength) : next)
setSelected([])
}
const paste = (e: React.ClipboardEvent) => {
if (disabled) return
const cell = (e.target as HTMLElement).closest<HTMLElement>('[data-cell]')
if (!cell || cell.closest('[data-nested]') !== e.currentTarget.closest('[data-nested]')) return
const text = e.clipboardData.getData('text/plain')
if (!/[\t\n]/.test(text.trim())) return
e.preventDefault()
const grid = text
.replace(/\r/g, '')
.replace(/\n+$/, '')
.split('\n')
.map((line) => line.split('\t'))
const r0 = Number(cell.dataset.row)
const c0 = Number(cell.dataset.col)
const next = [...rows]
let added = 0
let filled = 0
grid.forEach((cells, dr) => {
const ri = r0 + dr
if (ri >= next.length) {
if (field.maxLength !== undefined && next.length >= field.maxLength) return
next.push(blank())
added++
}
const row = { ...next[ri] }
cells.forEach((raw, dc) => {
const col = columns[c0 + dc]
if (!col || col.compute || col.readOnly || !PASTEABLE.has(col.type)) return
const v = fromPaste(col, raw)
if (v !== undefined) {
row[col.name] = v
filled++
}
})
next[ri] = row
})
onChange(next)
flash(
`${t('pasted', { count: filled })}${added ? ` · ${t('rowsAdded', { count: added })}` : ''}`,
)
}
const rowKeys = (i: number) => (e: React.KeyboardEvent) => {
const target = e.target as HTMLElement
if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault()
e.stopPropagation()
act(i, e.key === 'ArrowUp' ? 'up' : 'down')
} else if (
e.key === 'Enter' &&
!e.shiftKey &&
target.tagName === 'INPUT' &&
target.id.startsWith('field-') &&
i === rows.length - 1 &&
canAdd
) {
e.preventDefault()
e.stopPropagation()
add()
}
}
const control = (row: Row, i: number, col: FieldDef, j: number, label: boolean) => {
const ctx = ctxOf(row)
const key = `${path}.${i}.${col.name}`
const error = errors[key]
if (!isVisible(col, ctx, roles))
return label ? null : <span className="px-2 text-muted-foreground">—</span>
const node = (
<Control
id={`field-${key}`}
path={key}
field={label ? col : { ...col, label: `${col.label}, row ${i + 1}` }}
value={cellValue(row, col)}
values={ctx}
invalid={!!error}
disabled={!!disabled || isReadOnly(col, ctx, roles)}
describedBy={error ? `field-${key}-error` : undefined}
currency={currency}
errors={errors}
fieldTypes={fieldTypes}
onChange={(v) => setCell(i, col.name, v)}
/>
)
if (label)
return (
<div
key={col.name}
data-cell
data-row={i}
data-col={j}
className={cn(
'min-w-0',
(col.type === 'rows' ||
col.type === 'group' ||
col.type === 'textarea' ||
col.type === 'richtext') &&
'col-span-full',
)}
>
<Field
label={col.label}
htmlFor={`field-${key}`}
hint={col.hint ?? (col.compute ? t('calculated') : undefined)}
error={error}
required={!disabled && isRequired(col, ctx)}
>
{node}
</Field>
</div>
)
return (
<>
{node}
{error && (
<span id={`field-${key}-error`} className="mt-1 block text-xs text-destructive">
{error}
</span>
)}
</>
)
}
const totals =
field.totals?.length && rows.length
? field.totals.map((name) => {
const col = columns.find((c) => c.name === name)
const sum = rows.reduce((s, r) => s + (Number(col ? cellValue(r, col) : r[name]) || 0), 0)
const text = new Intl.NumberFormat('en-US', {
maximumFractionDigits: col?.decimals ?? 2,
}).format(sum)
return {
name,
label: col?.label ?? name,
text: `${col?.type === 'currency' ? currency : ''}${text}${col?.type === 'percent' ? '%' : ''}`,
}
})
: null
const allSelected = rows.length > 0 && selected.length === rows.length
const toggleAll = () => setSelected(allSelected ? [] : rows.map((r) => r.id))
const toggleOne = (rowId: string) =>
setSelected((s) => (s.includes(rowId) ? s.filter((x) => x !== rowId) : [...s, rowId]))
const detailHasError = (i: number) =>
details.some((d) =>
Object.keys(errors).some(
(k) =>
errors[k] && (k === `${path}.${i}.${d.name}` || k.startsWith(`${path}.${i}.${d.name}.`)),
),
)
const isOpen = (row: Row, i: number) => open.includes(row.id) || detailHasError(i)
const toggleOpen = (rowId: string) =>
setOpen((o) => (o.includes(rowId) ? o.filter((x) => x !== rowId) : [...o, rowId]))
const checkbox = (on: boolean, label: string, onToggle: () => void) => (
<button
type="button"
role="checkbox"
aria-checked={on}
aria-label={label}
onClick={onToggle}
className="flex size-6 items-center justify-center rounded"
>
<Check on={on} />
</button>
)
const span = (disabled ? 0 : 2) + 1 + (details.length ? 1 : 0) + columns.length
const leads = { check: 0, number: disabled ? 0 : 2, details: (disabled ? 0 : 2) + 2.25 }
const firstLeft = leads.details + (details.length ? 1.75 : 0)
const pinned = (part: 'head' | 'body' | 'foot', left: number, edge = false) => ({
style: { left: `${left}rem` },
className: cn(
'sticky z-10',
part === 'head' && 'bg-[color-mix(in_oklab,var(--muted)_50%,var(--card))]',
part === 'foot' && 'bg-[color-mix(in_oklab,var(--muted)_30%,var(--card))]',
part === 'body' && 'bg-card group-data-[selected=true]:bg-muted',
edge && 'shadow-[1px_0_0_var(--border)]',
),
})
return (
<div
ref={wrap}
id={id}
data-nested
aria-describedby={describedBy}
className="flex min-w-0 flex-col gap-2"
onPaste={paste}
>
{selected.length > 0 && (
<div className="flex items-center gap-2 rounded-lg bg-muted px-2 py-1.5 text-sm">
<span className="px-1 font-medium tabular-nums">
{t('selected', { count: selected.length })}
</span>
{canAdd && (
<button
type="button"
onClick={duplicateSelected}
className="flex h-7 items-center gap-1.5 rounded-md px-2 hover:bg-card [&_svg]:size-3.5"
>
<Copy /> {t('duplicate')}
</button>
)}
<button
type="button"
onClick={() => remove(selected)}
className="flex h-7 items-center gap-1.5 rounded-md px-2 text-destructive hover:bg-card [&_svg]:size-3.5"
>
<Trash2 /> {t('delete')}
</button>
<button
type="button"
onClick={() => setSelected([])}
className="ml-auto h-7 rounded-md px-2 text-muted-foreground hover:bg-card hover:text-foreground"
>
{t('clear')}
</button>
</div>
)}
{compact ? (
<ul className="flex flex-col gap-2">
{rows.map((row, i) => (
<li
key={row.id}
data-row={i}
onKeyDown={rowKeys(i)}
className={cn(
'rounded-lg border bg-card p-3',
selected.includes(row.id) && 'border-ring',
)}
>
<div className="mb-3 flex items-center gap-2">
{!disabled &&
checkbox(selected.includes(row.id), t('selectRow', { n: i + 1 }), () =>
toggleOne(row.id),
)}
<span className="flex-1 text-sm font-medium">{t('rowLabel', { n: i + 1 })}</span>
{!disabled && (
<ItemMenu
label={t('rowLabel', { n: i + 1 })}
index={i}
count={rows.length}
canAdd={canAdd}
onAction={(a) => act(i, a)}
/>
)}
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-3">
{all.map((col) => control(row, i, col, columns.indexOf(col), true))}
</div>
</li>
))}
{!rows.length && (
<li className="rounded-lg border border-dashed px-3 py-5 text-center text-sm text-muted-foreground">
{t('noRows')}
</li>
)}
{totals && (
<li className="flex flex-wrap justify-end gap-x-4 gap-y-1 px-1 text-sm">
{totals.map((t) => (
<span key={t.name}>
<span className="text-muted-foreground">Total {t.label.toLowerCase()}: </span>
<span className="font-medium tabular-nums">{t.text}</span>
</span>
))}
</li>
)}
</ul>
) : (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b bg-muted/50 text-left text-xs text-muted-foreground">
{!disabled && (
<th
style={pinned('head', leads.check).style}
className={cn(pinned('head', leads.check).className, 'w-8 min-w-8 pl-1.5')}
>
{checkbox(allSelected, t('selectAll'), toggleAll)}
</th>
)}
<th
style={pinned('head', leads.number).style}
className={cn(
pinned('head', leads.number).className,
'w-9 min-w-9 px-1 text-center font-medium',
)}
>
#
</th>
{details.length > 0 && (
<th
aria-label="Details"
style={pinned('head', leads.details).style}
className={cn(pinned('head', leads.details).className, 'w-7 min-w-7')}
/>
)}
{columns.map((c, j) => (
<th
key={c.name}
scope="col"
style={j === 0 ? pinned('head', firstLeft).style : undefined}
className={cn(
'px-2 py-1.5 font-medium whitespace-nowrap',
j === 0 && pinned('head', firstLeft, true).className,
)}
>
{c.label}
{c.required && <span aria-hidden> *</span>}
</th>
))}
{!disabled && <th className="w-9" aria-label="Actions" />}
</tr>
</thead>
<tbody>
{rows.map((row, i) => {
const expanded = details.length > 0 && isOpen(row, i)
const detailError = detailHasError(i)
const dropping = drag && drag.over === i && drag.from !== i
return (
<Fragment key={row.id}>
<tr
data-row={i}
draggable={armed === row.id}
onKeyDown={rowKeys(i)}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', row.id)
setDrag({ from: i, over: i })
}}
onDragOver={(e) => {
if (!drag) return
e.preventDefault()
if (drag.over !== i) setDrag({ ...drag, over: i })
}}
onDrop={(e) => {
e.preventDefault()
if (drag) onChange(move(rows, drag.from, drag.over))
setDrag(null)
setArmed(null)
}}
onDragEnd={() => {
setDrag(null)
setArmed(null)
}}
data-selected={selected.includes(row.id) || undefined}
className={cn(
'group border-b last:border-0',
selected.includes(row.id) && 'bg-muted',
drag?.from === i && 'opacity-50',
dropping &&
(drag!.over < drag!.from
? 'shadow-[inset_0_2px_0_var(--primary)]'
: 'shadow-[inset_0_-2px_0_var(--primary)]'),
)}
>
{!disabled && (
<td
style={pinned('body', leads.check).style}
className={cn(
pinned('body', leads.check).className,
'w-8 min-w-8 pl-1.5 align-top',
)}
>
<div className="flex h-11 items-center">
{checkbox(selected.includes(row.id), t('selectRow', { n: i + 1 }), () =>
toggleOne(row.id),
)}
</div>
</td>
)}
<td
style={pinned('body', leads.number).style}
className={cn(
pinned('body', leads.number).className,
'w-9 min-w-9 px-1 align-top',
)}
>
<div className="flex h-11 items-center justify-center text-xs text-muted-foreground tabular-nums">
{!disabled ? (
<>
<span className="group-focus-within:hidden group-hover:hidden">
{i + 1}
</span>
<button
type="button"
aria-label={t('dragRow', { n: i + 1 })}
title={t('dragRow', { n: i + 1 })}
onPointerDown={() => setArmed(row.id)}
onPointerUp={() => !drag && setArmed(null)}
className="hidden cursor-grab rounded p-0.5 group-focus-within:flex group-hover:flex hover:bg-muted active:cursor-grabbing [&_svg]:size-3.5"
>
<GripVertical />
</button>
</>
) : (
i + 1
)}
</div>
</td>
{details.length > 0 && (
<td
style={pinned('body', leads.details).style}
className={cn(
pinned('body', leads.details).className,
'w-7 min-w-7 align-top',
)}
>
<div className="flex h-11 items-center">
<button
type="button"
aria-expanded={expanded}
aria-label={t(expanded ? 'hideDetails' : 'showDetails', { n: i + 1 })}
onClick={() => toggleOpen(row.id)}
className="relative flex size-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5"
>
<ChevronRight
className={cn('transition-transform', expanded && 'rotate-90')}
/>
{detailError && (
<span
aria-hidden
className="absolute top-0.5 right-0.5 size-1.5 rounded-full bg-destructive"
/>
)}
</button>
</div>
</td>
)}
{columns.map((col, j) => (
<td
key={col.name}
data-cell
data-row={i}
data-col={j}
style={j === 0 ? pinned('body', firstLeft).style : undefined}
className={cn(
'px-1.5 py-1.5 align-top',
cellWidth[col.type] ?? 'min-w-36',
j === 0 && pinned('body', firstLeft, true).className,
)}
>
{control(row, i, col, j, false)}
</td>
))}
{!disabled && (
<td className="px-1 align-top">
<div className="flex h-11 items-center">
<ItemMenu
label={t('rowLabel', { n: i + 1 })}
index={i}
count={rows.length}
canAdd={canAdd}
onAction={(a) => act(i, a)}
/>
</div>
</td>
)}
</tr>
{expanded && (
<tr data-row={i} className="border-b bg-muted/20" onKeyDown={rowKeys(i)}>
<td colSpan={span} className="p-0">
<div
className="sticky left-0 grid grid-cols-[repeat(auto-fill,minmax(12rem,1fr))] gap-3 px-3 py-3"
style={width ? { width: width - 2 } : undefined}
>
{details.map((d) => control(row, i, d, -1, true))}
</div>
</td>
</tr>
)}
</Fragment>
)
})}
{!rows.length && (
<tr>
<td
colSpan={span}
className="px-3 py-6 text-center text-sm text-muted-foreground"
>
{canAdd ? t('noRowsHint') : t('noRows')}
</td>
</tr>
)}
</tbody>
{totals && (
<tfoot>
<tr className="border-t bg-muted/30 font-medium">
{!disabled && <td {...pinned('foot', leads.check)} />}
<td
style={pinned('foot', leads.number).style}
className={cn(
pinned('foot', leads.number).className,
'px-1 py-1.5 text-center text-xs text-muted-foreground',
)}
>
Σ
</td>
{details.length > 0 && <td {...pinned('foot', leads.details)} />}
{columns.map((c, j) => (
<td
key={c.name}
style={j === 0 ? pinned('foot', firstLeft).style : undefined}
className={cn(
'px-3 py-1.5 tabular-nums',
j === 0 && pinned('foot', firstLeft, true).className,
)}
>
{totals.find((t) => t.name === c.name)?.text ?? ''}
</td>
))}
{!disabled && <td />}
</tr>
</tfoot>
)}
</table>
</div>
)}
{undo && (
<UndoBar
message={undo.message}
onUndo={() => {
undo.restore()
dismiss()
}}
onDismiss={dismiss}
/>
)}
{!disabled && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
{canAdd && (
<button
type="button"
onClick={add}
className="flex h-7 items-center gap-1 rounded-lg px-2 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-3.5"
>
<Plus /> {field.addLabel ?? t('addRow')}
</button>
)}
<span aria-live="polite" className="text-xs text-muted-foreground">
{note || (rows.length > 0 && !compact ? t('pasteTip') : '')}
</span>
</div>
)}
</div>
)
}import {
formatDate,
formatDateRange,
formatDateTime,
formatMonth,
formatTime,
thisMonthIso,
todayIso,
} from './dates'
import { defaultT, type T } from './i18n'
export type BuiltInFieldType =
| 'text'
| 'textarea'
| 'email'
| 'phone'
| 'url'
| 'number'
| 'currency'
| 'date'
| 'time'
| 'month'
| 'select'
| 'multiselect'
| 'toggle'
| 'radio'
| 'list'
| 'file'
| 'rows'
| 'lookup'
| 'address'
| 'daterange'
| 'datetime'
| 'percent'
| 'duration'
| 'richtext'
| 'signature'
| 'group'
export type FieldType = BuiltInFieldType | (string & {})
export type OptionObject = {
value: string
label: string
description?: string
avatar?: string
color?: string
}
export type Option = string | OptionObject
export type FileItem = {
id: string
name: string
size: number
type: string
url?: string
status?: 'uploading' | 'done' | 'error'
progress?: number
error?: string
}
export type Row = { id: string; [column: string]: FieldValue | undefined }
export type FieldValue = string | number | boolean | null | string[] | FileItem[] | Row[]
export type FormValues = Record<string, FieldValue | undefined>
export type FormErrors = Record<string, string>
export type Condition =
| {
field: string
is?: FieldValue | FieldValue[]
not?: FieldValue | FieldValue[]
filled?: boolean
}
| { any: Condition[] }
| { all: Condition[] }
export type Rule =
| 'required'
| 'type'
| 'min'
| 'max'
| 'minLength'
| 'maxLength'
| 'pattern'
| 'maxSize'
| 'accept'
| 'sum'
export type FieldDef = {
name: string
label: string
type: FieldType
required?: boolean
requiredWhen?: Condition | Condition[]
visibleWhen?: Condition | Condition[]
readOnly?: boolean
readOnlyWhen?: Condition | Condition[]
compute?: (values: FormValues) => FieldValue
options?: Option[]
display?: 'segmented' | 'cards' | 'checkboxes'
creatable?: boolean
placeholder?: string
hint?: string
span?: 1 | 2 | 3 | 4 | 'full'
min?: number | string
max?: number | string
step?: number
minLength?: number
maxLength?: number
pattern?: string
accept?: string
maxSize?: number
multiple?: boolean
columns?: FieldDef[]
details?: FieldDef[]
fields?: FieldDef[]
itemLabel?: string
titleField?: string
groupColumns?: 1 | 2 | 3
totals?: string[]
sum?: { column: string; equals?: number; min?: number; max?: number }
addLabel?: string
messages?: Partial<Record<Rule, string>>
search?: (query: string) => Promise<OptionObject[]>
decimals?: number
defaultCountry?: string
itemLabelPlural?: string
permissions?: Permissions
props?: Record<string, unknown>
}
export type Permissions = { view?: string[]; edit?: string[] }
export type Context = { t?: T; locale?: string; roles?: string[] }
export type FormSection = {
id: string
title: string
description?: string
icon?: React.ReactNode
columns?: 1 | 2 | 3 | 4
span?: 1 | 'full'
defaultOpen?: boolean
visibleWhen?: Condition | Condition[]
permissions?: Permissions
fields: FieldDef[]
}
export type CustomFieldType = {
validate?: (
value: FieldValue | undefined,
field: FieldDef,
values: FormValues,
) => string | undefined
isEmpty?: (value: FieldValue | undefined) => boolean
format?: (value: FieldValue | undefined, field: FieldDef) => string
}
export type FieldTypeMap = Record<string, CustomFieldType>
export type Change = { id: string; label: string; from: string; to: string }
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
const URL_PATTERN = /^(https?:\/\/)?[\w-]+(\.[\w-]+)+(\/\S*)?$/
export function normalizeOptions(options: Option[] = []): OptionObject[] {
return options.map((o) => (typeof o === 'string' ? { value: o, label: o } : o))
}
const known = new Map<string, OptionObject>()
export function rememberOptions(options: OptionObject[]) {
for (const o of options) known.set(o.value, o)
}
export function findOption(field: Pick<FieldDef, 'options'>, value: string) {
return normalizeOptions(field.options).find((o) => o.value === value) ?? known.get(value)
}
export function optionLabel(field: Pick<FieldDef, 'options'>, value: string) {
return findOption(field, value)?.label ?? value
}
export function plainText(html: string) {
return html
.replace(/<(br|\/p|\/li|\/div)[^>]*>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/ /g, ' ')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/\n{2,}/g, '\n')
.trim()
}
export function parseDuration(text: string): number | null {
const t = text.trim().toLowerCase().replace(/\s+/g, '')
if (!t) return null
if (/^\d+(\.\d+)?$/.test(t)) return Math.round(Number(t))
const clock = /^(\d+):([0-5]\d)$/.exec(t)
if (clock) return Number(clock[1]) * 60 + Number(clock[2])
const parts = /^(?:(\d+(?:\.\d+)?)d)?(?:(\d+(?:\.\d+)?)h)?(?:(\d+)m(?:in)?)?$/.exec(t)
if (!parts || !(parts[1] || parts[2] || parts[3])) return null
return Math.round(
Number(parts[1] ?? 0) * 1440 + Number(parts[2] ?? 0) * 60 + Number(parts[3] ?? 0),
)
}
export function showDuration(minutes: number) {
if (!minutes) return '0m'
const d = Math.floor(minutes / 1440)
const h = Math.floor((minutes % 1440) / 60)
const m = minutes % 60
return [d && `${d}d`, h && `${h}h`, m && `${m}m`].filter(Boolean).join(' ')
}
export function phoneDigits(value: string) {
return value.replace(/\D/g, '')
}
export function formatNumber(value: number, decimals?: number, locale?: string) {
return new Intl.NumberFormat(locale ?? 'en-US', {
minimumFractionDigits: decimals ?? 0,
maximumFractionDigits: decimals ?? 20,
}).format(value)
}
const isRichEmpty = (value: FieldValue | undefined) =>
typeof value === 'string' && !plainText(value) && !/<img/i.test(value)
export function isEmptyValue(value: FieldValue | undefined) {
return (
value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0)
)
}
const matches = (value: FieldValue | undefined, expected: FieldValue | FieldValue[]) => {
const wanted = Array.isArray(expected) ? expected : [expected]
if (Array.isArray(value)) return value.some((v) => wanted.includes(v as never))
return wanted.some((w) => w === value || (w !== null && String(w) === String(value ?? '')))
}
export function evaluate(
condition: Condition | Condition[] | undefined,
values: FormValues,
): boolean {
if (!condition) return true
if (Array.isArray(condition)) return condition.every((c) => evaluate(c, values))
if ('any' in condition) return condition.any.some((c) => evaluate(c, values))
if ('all' in condition) return condition.all.every((c) => evaluate(c, values))
const value = values[condition.field]
if (condition.filled !== undefined && isEmptyValue(value) === condition.filled) return false
if (condition.is !== undefined && !matches(value, condition.is)) return false
if (condition.not !== undefined && matches(value, condition.not)) return false
return true
}
const allowed = (list: string[] | undefined, roles: string[] | undefined) =>
!list || list.length === 0 || (!!roles && roles.some((r) => list.includes(r)))
export const canView = (item: { permissions?: Permissions }, roles?: string[]) =>
allowed(item.permissions?.view, roles)
export const canEdit = (item: { permissions?: Permissions }, roles?: string[]) =>
allowed(item.permissions?.edit, roles)
export const isVisible = (
item: { visibleWhen?: FieldDef['visibleWhen']; permissions?: Permissions },
values: FormValues,
roles?: string[],
) => canView(item, roles) && evaluate(item.visibleWhen, values)
export const isRequired = (field: FieldDef, values: FormValues) =>
!!field.required || (field.requiredWhen !== undefined && evaluate(field.requiredWhen, values))
export const isReadOnly = (field: FieldDef, values: FormValues, roles?: string[]) =>
!!field.readOnly ||
!!field.compute ||
!canEdit(field, roles) ||
(field.readOnlyWhen !== undefined && evaluate(field.readOnlyWhen, values))
export function visibleFields(sections: FormSection[], values: FormValues, roles?: string[]) {
return sections
.filter((s) => isVisible(s, values, roles))
.flatMap((s) =>
canEdit(s, roles) ? s.fields : s.fields.map((f) => ({ ...f, readOnly: true })),
)
.filter((f) => isVisible(f, values, roles))
}
export function editableFields(sections: FormSection[], values: FormValues, roles?: string[]) {
return visibleFields(sections, values, roles).filter(
(f) => !isReadOnly(f, values, roles) || f.compute,
)
}
export const itemFields = (field: FieldDef) =>
field.type === 'group'
? (field.fields ?? [])
: [...(field.columns ?? []), ...(field.details ?? [])]
export const isNested = (field: FieldDef) => field.type === 'rows' || field.type === 'group'
export const scope = (values: FormValues, item: Row): FormValues => ({ ...values, ...item })
export function computeItems(field: FieldDef, items: Row[], values: FormValues): Row[] {
return items.map((item) => {
let next = item
for (const sub of itemFields(field)) {
if (sub.compute) {
const v = sub.compute(scope(values, next))
if (JSON.stringify(v) !== JSON.stringify(next[sub.name])) next = { ...next, [sub.name]: v }
}
const nested = next[sub.name]
if (isNested(sub) && Array.isArray(nested)) {
const inner = computeItems(sub, nested as Row[], scope(values, next))
if (JSON.stringify(inner) !== JSON.stringify(nested)) next = { ...next, [sub.name]: inner }
}
}
return next
})
}
export function applyComputed(sections: FormSection[], values: FormValues): FormValues {
let next = values
for (const field of sections.flatMap((s) => s.fields)) {
if (field.compute) {
const value = field.compute(next)
if (JSON.stringify(value) !== JSON.stringify(next[field.name]))
next = { ...next, [field.name]: value }
}
const items = next[field.name]
if (isNested(field) && Array.isArray(items)) {
const computed = computeItems(field, items as Row[], next)
if (JSON.stringify(computed) !== JSON.stringify(items))
next = { ...next, [field.name]: computed }
}
}
return next
}
const limitOf = (limit: number | string, type: FieldType) =>
limit === 'today' ? (type === 'month' ? thisMonthIso() : todayIso()) : String(limit)
export function showDate(iso: string, locale?: string) {
return formatDate(iso, locale)
}
export function showMonth(iso: string, locale?: string) {
return formatMonth(iso, locale)
}
export function showTime(hhmm: string, locale?: string) {
return formatTime(hhmm, locale)
}
export function showDateTime(value: string, locale?: string) {
return formatDateTime(value, locale)
}
export function showSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const showLimit = (limit: string, type: FieldType, locale?: string) =>
type === 'date'
? showDate(limit, locale)
: type === 'month'
? showMonth(limit, locale)
: type === 'time'
? showTime(limit, locale)
: limit
function acceptsFile(accept: string, file: FileItem) {
return accept
.split(',')
.map((a) => a.trim().toLowerCase())
.some((a) =>
a.startsWith('.')
? file.name.toLowerCase().endsWith(a)
: a.endsWith('/*')
? file.type.startsWith(a.slice(0, -1))
: file.type === a,
)
}
function nounFor(field: FieldDef, count: number, t: T) {
if (field.type === 'group') {
const one = (field.itemLabel ?? t('noun.item_one')).toLowerCase()
return count === 1 ? one : (field.itemLabelPlural ?? `${one}s`).toLowerCase()
}
const key =
field.type === 'rows'
? 'noun.row'
: field.type === 'file'
? 'noun.file'
: field.type === 'list'
? 'noun.item'
: 'noun.option'
return t(key, { count })
}
export function validateField(
field: FieldDef,
value: FieldValue | undefined,
values: FormValues = {},
fieldTypes: FieldTypeMap = {},
context: Context = {},
): string | undefined {
const t = context.t ?? defaultT
const { locale } = context
const say = (rule: Rule, key: string, vars: Record<string, unknown> = {}) => {
const all = { label: field.label, ...vars }
const template = field.messages?.[rule]
if (!template) return t(key, all)
return template.replace(/\{(\w+)\}/g, (_, k: string) =>
String(all[k as keyof typeof all] ?? field[k as keyof FieldDef] ?? ''),
)
}
const count = (rule: Rule, key: 'minCount' | 'maxCount', n: number) =>
say(rule, key, { count: n, noun: nounFor(field, n, t), min: n, max: n })
const custom = fieldTypes[field.type]
const empty = custom?.isEmpty
? custom.isEmpty(value)
: isEmptyValue(value) ||
(field.type === 'richtext' && isRichEmpty(value)) ||
(field.type === 'daterange' && value === '/')
if (empty && (field.type === 'rows' || field.type === 'group') && (field.minLength ?? 0) > 0)
return count('minLength', 'minCount', field.minLength!)
if (empty) return isRequired(field, values) ? say('required', 'required') : undefined
if (custom?.validate) {
const error = custom.validate(value, field, values)
if (error) return error
}
const { min, max, minLength, maxLength, type } = field
if (type === 'file' && Array.isArray(value)) {
const files = value as FileItem[]
if (files.some((f) => f.status === 'uploading')) return t('uploading')
if (files.some((f) => f.status === 'error')) return t('uploadFailed')
if (maxLength !== undefined && files.length > maxLength)
return count('maxLength', 'maxCount', maxLength)
const big = field.maxSize ? files.find((f) => f.size > field.maxSize!) : undefined
if (big)
return say('maxSize', 'maxSize', {
name: big.name,
max: showSize(field.maxSize!),
maxSize: showSize(field.maxSize!),
})
const wrong = field.accept ? files.find((f) => !acceptsFile(field.accept!, f)) : undefined
if (wrong) return say('accept', 'accept', { name: wrong.name })
return undefined
}
if ((type === 'rows' || type === 'group') && Array.isArray(value)) {
const rows = value as Row[]
if (minLength !== undefined && rows.length < minLength)
return count('minLength', 'minCount', minLength)
if (maxLength !== undefined && rows.length > maxLength)
return count('maxLength', 'maxCount', maxLength)
if (field.sum) {
const { column, equals, min: lo, max: hi } = field.sum
const total = Math.round(rows.reduce((s, r) => s + (Number(r[column]) || 0), 0) * 100) / 100
const label = field.columns?.find((c) => c.name === column)?.label ?? column
if (equals !== undefined && total !== equals)
return say('sum', 'sumEquals', { label, equals, total })
if (lo !== undefined && total < lo) return say('sum', 'sumMin', { label, min: lo, total })
if (hi !== undefined && total > hi) return say('sum', 'sumMax', { label, max: hi, total })
}
return undefined
}
if (Array.isArray(value)) {
if (minLength !== undefined && value.length < minLength)
return count('minLength', 'minCount', minLength)
if (maxLength !== undefined && value.length > maxLength)
return count('maxLength', 'maxCount', maxLength)
return undefined
}
if (type === 'duration') {
const n = Number(value)
if (Number.isNaN(n)) return say('type', 'duration')
if (min !== undefined && n < Number(min))
return say('min', 'minNumber', { min: showDuration(Number(min)) })
if (max !== undefined && n > Number(max))
return say('max', 'maxNumber', { max: showDuration(Number(max)) })
return undefined
}
if (type === 'number' || type === 'currency' || type === 'percent' || typeof value === 'number') {
const n = Number(value)
if (Number.isNaN(n)) return say('type', 'number')
if (min !== undefined && n < Number(min))
return say('min', 'minNumber', { min: formatNumber(Number(min), undefined, locale) })
if (max !== undefined && n > Number(max))
return say('max', 'maxNumber', { max: formatNumber(Number(max), undefined, locale) })
return undefined
}
if (typeof value !== 'string') return undefined
if (type === 'phone') {
const digits = phoneDigits(value)
return digits.length < 6 || digits.length > 15 ? say('type', 'phone') : undefined
}
if (type === 'richtext') {
const length = plainText(value).length
if (maxLength !== undefined && length > maxLength)
return say('maxLength', 'maxChars', { count: maxLength, max: maxLength })
if (minLength !== undefined && length < minLength)
return say('minLength', 'minChars', { count: minLength, min: minLength })
return undefined
}
if (type === 'daterange') {
const [start, end] = value.split('/')
if (!start || !end) return say('type', 'rangeIncomplete')
if (end < start) return say('type', 'rangeOrder')
if (min !== undefined && start < limitOf(min, 'date'))
return say('min', 'rangeMin', { min: showDate(limitOf(min, 'date'), locale) })
if (max !== undefined && end > limitOf(max, 'date'))
return say('max', 'rangeMax', { max: showDate(limitOf(max, 'date'), locale) })
return undefined
}
if (type === 'datetime') {
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(value)) return say('type', 'datetime')
if (min !== undefined && value < String(min))
return say('min', 'minTime', { min: showDateTime(String(min), locale) })
if (max !== undefined && value > String(max))
return say('max', 'maxTime', { max: showDateTime(String(max), locale) })
return undefined
}
if (type === 'email' && !EMAIL.test(value)) return say('type', 'email')
if (type === 'url' && !URL_PATTERN.test(value)) return say('type', 'url')
if (type === 'date' || type === 'time' || type === 'month') {
const [minKey, maxKey] = type === 'time' ? ['minTime', 'maxTime'] : ['minDate', 'maxDate']
if (min !== undefined && value < limitOf(min, type))
return say('min', minKey, { min: showLimit(limitOf(min, type), type, locale) })
if (max !== undefined && value > limitOf(max, type))
return say('max', maxKey, { max: showLimit(limitOf(max, type), type, locale) })
return undefined
}
if (minLength !== undefined && value.length < minLength)
return say('minLength', 'minChars', { count: minLength, min: minLength })
if (maxLength !== undefined && value.length > maxLength)
return say('maxLength', 'maxChars', { count: maxLength, max: maxLength })
if (field.pattern && !new RegExp(field.pattern).test(value)) return say('pattern', 'pattern')
return undefined
}
function validateItems(
field: FieldDef,
items: Row[],
values: FormValues,
path: string,
fieldTypes: FieldTypeMap,
context: Context,
errors: FormErrors,
) {
items.forEach((item, i) => {
const ctx = scope(values, item)
for (const sub of itemFields(field)) {
if (!isVisible(sub, ctx, context.roles)) continue
const value = sub.compute ? sub.compute(ctx) : item[sub.name]
const key = `${path}.${i}.${sub.name}`
const error = validateField(sub, value, ctx, fieldTypes, context)
if (error) errors[key] = error
if (isNested(sub) && Array.isArray(value))
validateItems(sub, value as Row[], ctx, key, fieldTypes, context, errors)
}
})
}
export function validateFields(
fields: FieldDef[],
values: FormValues,
fieldTypes: FieldTypeMap = {},
context: Context = {},
): FormErrors {
const errors: FormErrors = {}
for (const field of fields) {
if (!isVisible(field, values, context.roles)) continue
const value = field.compute ? field.compute(values) : values[field.name]
const error = validateField(field, value, values, fieldTypes, context)
if (error) errors[field.name] = error
if (isNested(field) && Array.isArray(value))
validateItems(field, value as Row[], values, field.name, fieldTypes, context, errors)
}
return errors
}
export function validateRecord(
sections: FormSection[],
values: FormValues,
fieldTypes: FieldTypeMap = {},
context: Context = {},
) {
return validateFields(visibleFields(sections, values, context.roles), values, fieldTypes, context)
}
export function formatValue(
field: FieldDef,
value: FieldValue | undefined,
fieldTypes: FieldTypeMap = {},
context: Context = {},
): string {
const t = context.t ?? defaultT
const { locale } = context
const custom = fieldTypes[field.type]
if (custom?.format) return custom.format(value, field)
if (isEmptyValue(value) || (field.type === 'richtext' && isRichEmpty(value))) return t('empty')
if (typeof value === 'boolean') return value ? t('yes') : t('no')
if (field.type === 'file') return (value as FileItem[]).map((f) => f.name).join(', ')
if (field.type === 'rows') return t('rows', { count: (value as Row[]).length })
if (field.type === 'group') {
const n = (value as Row[]).length
return `${n} ${nounFor(field, n, t)}`
}
if (Array.isArray(value)) return (value as string[]).map((v) => optionLabel(field, v)).join(', ')
const text = String(value)
if (field.type === 'date') return showDate(text, locale)
if (field.type === 'datetime') return showDateTime(text, locale)
if (field.type === 'daterange') return formatDateRange(text, locale) || t('empty')
if (field.type === 'lookup') return optionLabel(field, text)
if (field.type === 'richtext') return plainText(text)
if (field.type === 'signature') return t('signed')
if (field.type === 'duration') return showDuration(Number(text))
if (field.type === 'percent') return `${formatNumber(Number(text), field.decimals, locale)}%`
if ((field.type === 'number' || field.type === 'currency') && !Number.isNaN(Number(text)))
return formatNumber(Number(text), field.decimals, locale)
if (field.type === 'month') return showMonth(text, locale)
if (field.type === 'time') return showTime(text, locale)
if (field.type === 'select' || field.type === 'radio') return optionLabel(field, text)
return text
}
export function diffValues(
sections: FormSection[],
before: FormValues,
after: FormValues,
fieldTypes: FieldTypeMap = {},
context: Context = {},
): Change[] {
const changes: Change[] = []
for (const field of visibleFields(sections, after, context.roles)) {
const a = formatValue(field, before[field.name], fieldTypes, context)
const b = formatValue(field, after[field.name], fieldTypes, context)
if (a !== b) changes.push({ id: field.name, label: field.label, from: a, to: b })
else if (JSON.stringify(before[field.name]) !== JSON.stringify(after[field.name]))
changes.push({ id: field.name, label: field.label, from: a, to: `${b} *` })
}
return changes
}'use client'
import { useEffect, useRef, useState } from 'react'
import { Maximize2, Undo2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Dialog } from '@/registry/dashboard/components/dialog/dialog'
import { useFormConfig } from './context'
type Point = { x: number; y: number }
type Stroke = Point[]
type Mode = 'draw' | 'type'
const W = 1000
const H = 250
const INK = '#111111'
const FONT = 'italic 96px "Brush Script MT", "Segoe Script", "Snell Roundhand", cursive'
function paint(
canvas: HTMLCanvasElement,
strokes: Stroke[],
typed: string,
mode: Mode,
base: HTMLImageElement | null,
) {
const ctx = canvas.getContext('2d')
if (!ctx) return
const scale = canvas.width / W
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.setTransform(scale, 0, 0, scale, 0, 0)
if (base) ctx.drawImage(base, 0, 0, W, H)
ctx.strokeStyle = INK
ctx.fillStyle = INK
if (mode === 'type') {
if (!typed.trim()) return
ctx.font = FONT
ctx.textBaseline = 'middle'
ctx.fillText(typed, 40, H / 2, W - 80)
return
}
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.lineWidth = 5
for (const stroke of strokes) {
if (!stroke.length) continue
ctx.beginPath()
ctx.moveTo(stroke[0].x, stroke[0].y)
if (stroke.length === 1) ctx.lineTo(stroke[0].x + 0.1, stroke[0].y)
for (let i = 1; i < stroke.length - 1; i++) {
const mid = { x: (stroke[i].x + stroke[i + 1].x) / 2, y: (stroke[i].y + stroke[i + 1].y) / 2 }
ctx.quadraticCurveTo(stroke[i].x, stroke[i].y, mid.x, mid.y)
}
if (stroke.length > 1) ctx.lineTo(stroke.at(-1)!.x, stroke.at(-1)!.y)
ctx.stroke()
}
}
function exportPng(strokes: Stroke[], typed: string, mode: Mode, base: HTMLImageElement | null) {
const canvas = document.createElement('canvas')
canvas.width = W * 2
canvas.height = H * 2
paint(canvas, strokes, typed, mode, base)
return canvas.toDataURL('image/png')
}
function Pad({
strokes,
typed,
mode,
base,
label,
onStroke,
className,
}: {
strokes: Stroke[]
typed: string
mode: Mode
base: HTMLImageElement | null
label: string
onStroke: (stroke: Stroke) => void
className?: string
}) {
const canvas = useRef<HTMLCanvasElement>(null)
const live = useRef<Stroke | null>(null)
const latest = useRef({ strokes, typed, mode, base })
useEffect(() => {
latest.current = { strokes, typed, mode, base }
if (canvas.current) paint(canvas.current, strokes, typed, mode, base)
}, [strokes, typed, mode, base])
useEffect(() => {
const c = canvas.current
if (!c) return
const observer = new ResizeObserver(() => {
const ratio = window.devicePixelRatio || 1
c.width = Math.round(c.clientWidth * ratio)
c.height = Math.round(c.clientHeight * ratio)
const { strokes: s, typed: t, mode: m, base: b } = latest.current
paint(c, s, t, m, b)
})
observer.observe(c)
return () => observer.disconnect()
}, [])
const point = (e: React.PointerEvent<HTMLCanvasElement>): Point => {
const rect = e.currentTarget.getBoundingClientRect()
return {
x: ((e.clientX - rect.left) / rect.width) * W,
y: ((e.clientY - rect.top) / rect.height) * H,
}
}
const repaintLive = () => {
const c = canvas.current
if (!c || !live.current) return
paint(c, [...strokes, live.current], typed, mode, base)
}
return (
<canvas
ref={canvas}
role="img"
aria-label={label}
className={cn(
'block aspect-[4/1] w-full touch-none',
mode === 'draw' && 'cursor-crosshair',
className,
)}
onPointerDown={(e) => {
if (mode !== 'draw') return
e.currentTarget.setPointerCapture(e.pointerId)
live.current = [point(e)]
repaintLive()
}}
onPointerMove={(e) => {
if (!live.current) return
const events = e.nativeEvent.getCoalescedEvents?.() ?? [e.nativeEvent]
const rect = e.currentTarget.getBoundingClientRect()
for (const ev of events)
live.current.push({
x: ((ev.clientX - rect.left) / rect.width) * W,
y: ((ev.clientY - rect.top) / rect.height) * H,
})
repaintLive()
}}
onPointerUp={() => {
if (!live.current) return
const stroke = live.current
live.current = null
onStroke(stroke)
}}
onPointerCancel={() => {
live.current = null
repaintLive()
}}
/>
)
}
function Paper({ children, invalid }: { children: React.ReactNode; invalid?: boolean }) {
return (
<div
className={cn(
'relative overflow-hidden rounded-lg border bg-white text-neutral-900',
invalid ? 'border-destructive' : 'border-input',
)}
>
{children}
<span
aria-hidden
className="pointer-events-none absolute right-[4%] bottom-[22%] left-[4%] border-b border-dashed border-neutral-300"
/>
</div>
)
}
function Controls({
mode,
typed,
canUndo,
canClear,
onMode,
onTyped,
onUndo,
onClear,
children,
}: {
mode: Mode
typed: string
canUndo: boolean
canClear: boolean
onMode: (mode: Mode) => void
onTyped: (text: string) => void
onUndo: () => void
onClear: () => void
children?: React.ReactNode
}) {
const { t } = useFormConfig()
return (
<div className="flex flex-wrap items-center gap-2">
<div
role="radiogroup"
aria-label={t('signatureMethod')}
className="flex rounded-md bg-muted p-0.5 text-xs"
>
{(['draw', 'type'] as const).map((m) => (
<button
key={m}
type="button"
role="radio"
aria-checked={mode === m}
onClick={() => onMode(m)}
className={cn(
'h-6 rounded px-2 capitalize',
mode === m ? 'bg-card font-medium shadow-(--shadow-button)' : 'text-muted-foreground',
)}
>
{t(m)}
</button>
))}
</div>
{mode === 'type' && (
<input
autoFocus
value={typed}
placeholder={t('typeName')}
aria-label={t('typeName')}
onChange={(e) => onTyped(e.target.value)}
className="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 text-sm outline-none focus:border-ring"
/>
)}
<div className="ml-auto flex items-center gap-1">
{mode === 'draw' && (
<button
type="button"
onClick={onUndo}
disabled={!canUndo}
className="flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 [&_svg]:size-3"
>
<Undo2 /> {t('undo')}
</button>
)}
<button
type="button"
onClick={onClear}
disabled={!canClear}
className="h-7 rounded-md px-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40"
>
{t('clear')}
</button>
{children}
</div>
</div>
)
}
export function SignatureField({
id,
value,
disabled,
invalid,
describedBy,
onChange,
}: {
id: string
value: string
disabled?: boolean
invalid?: boolean
describedBy?: string
onChange: (value: string) => void
}) {
const { t } = useFormConfig()
const [strokes, setStrokes] = useState<Stroke[]>([])
const [typed, setTyped] = useState('')
const [mode, setMode] = useState<Mode>('draw')
const [base, setBase] = useState<HTMLImageElement | null>(null)
const [expanded, setExpanded] = useState(false)
const snapshot = useRef<{
strokes: Stroke[]
typed: string
mode: Mode
base: HTMLImageElement | null
value: string
} | null>(null)
const loaded = useRef(false)
useEffect(() => {
if (loaded.current || !value) return
loaded.current = true
const img = new Image()
img.onload = () => setBase(img)
img.src = value
}, [value])
const update = (next: {
strokes?: Stroke[]
typed?: string
mode?: Mode
base?: HTMLImageElement | null
}) => {
const s = next.strokes ?? strokes
const t = next.typed ?? typed
const m = next.mode ?? mode
const b = next.base === undefined ? base : next.base
if (next.strokes) setStrokes(s)
if (next.typed !== undefined) setTyped(t)
if (next.mode) setMode(m)
if (next.base !== undefined) setBase(b)
const empty = m === 'type' ? !t.trim() : !s.length && !b
onChange(empty ? '' : exportPng(s, t, m, b))
}
const clear = () => update({ strokes: [], typed: '', base: null })
const hasInk = mode === 'type' ? !!typed.trim() : strokes.length > 0 || !!base
const open = () => {
snapshot.current = { strokes, typed, mode, base, value }
setExpanded(true)
}
const cancel = () => {
const s = snapshot.current
if (s) {
setStrokes(s.strokes)
setTyped(s.typed)
setMode(s.mode)
setBase(s.base)
onChange(s.value)
}
setExpanded(false)
}
if (disabled) {
return value ? (
<div className="overflow-hidden rounded-lg border bg-white">
<img
id={id}
src={value}
alt={t('sign')}
className="block aspect-[4/1] w-full object-contain"
/>
</div>
) : (
<span id={id} className="text-sm text-muted-foreground">
{t('notSigned')}
</span>
)
}
const controls = (
<Controls
mode={mode}
typed={typed}
canUndo={strokes.length > 0}
canClear={hasInk}
onMode={(m) => update({ mode: m, strokes: [], typed: '', base: null })}
onTyped={(t) => update({ typed: t })}
onUndo={() => update({ strokes: strokes.slice(0, -1) })}
onClear={clear}
/>
)
return (
<div id={id} aria-describedby={describedBy} className="flex flex-col gap-1.5">
<Paper invalid={invalid}>
<Pad
strokes={strokes}
typed={typed}
mode={mode}
base={base}
label={
mode === 'draw' ? 'Signature pad. Draw with a mouse, finger or pen.' : 'Typed signature'
}
onStroke={(s) => update({ strokes: [...strokes, s] })}
/>
{!hasInk && mode === 'draw' && (
<span className="pointer-events-none absolute bottom-[6%] left-[4%] text-xs text-neutral-400">
{t('signAbove')}
</span>
)}
<button
type="button"
aria-label={t('expandSignature')}
title={t('expand')}
onClick={open}
className="absolute top-2 right-2 flex size-7 items-center justify-center rounded-md bg-white/90 text-neutral-500 shadow-sm ring-1 ring-neutral-200 hover:text-neutral-900 [&_svg]:size-3.5"
>
<Maximize2 />
</button>
</Paper>
{controls}
<Dialog
open={expanded}
onOpenChange={(next) => !next && cancel()}
title={t('sign')}
size="full"
closeLabel={t('closeWithoutSaving')}
>
<div className="flex flex-col gap-3">
<Paper>
<Pad
strokes={strokes}
typed={typed}
mode={mode}
base={base}
label={t('signaturePad')}
onStroke={(s) => update({ strokes: [...strokes, s] })}
/>
{!hasInk && mode === 'draw' && (
<span className="pointer-events-none absolute bottom-[6%] left-[4%] text-sm text-neutral-400">
{t('signAbove')}
</span>
)}
</Paper>
<Controls
mode={mode}
typed={typed}
canUndo={strokes.length > 0}
canClear={hasInk}
onMode={(m) => update({ mode: m, strokes: [], typed: '', base: null })}
onTyped={(t) => update({ typed: t })}
onUndo={() => update({ strokes: strokes.slice(0, -1) })}
onClear={clear}
>
<button
type="button"
data-autofocus
onClick={() => setExpanded(false)}
className="ml-1 h-8 rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{t('done')}
</button>
</Controls>
</div>
</Dialog>
</div>
)
}export type UploadContext = {
onProgress: (percent: number) => void
signal: AbortSignal
}
export type UploadResult = { url: string; id?: string }
export type Uploader = (file: File, context: UploadContext) => Promise<UploadResult>
export function xhrUploader({
url,
field = 'file',
headers,
method = 'POST',
parse = (body) => body as UploadResult,
}: {
url: string
field?: string
headers?: Record<string, string>
method?: 'POST' | 'PUT'
parse?: (body: unknown) => UploadResult
}): Uploader {
return (file, { onProgress, signal }) =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open(method, url)
for (const [k, v] of Object.entries(headers ?? {})) xhr.setRequestHeader(k, v)
xhr.upload.onprogress = (e) => e.lengthComputable && onProgress((e.loaded / e.total) * 100)
xhr.onload = () => {
if (xhr.status < 200 || xhr.status >= 300)
return reject(new Error(`Upload failed (${xhr.status})`))
try {
resolve(parse(xhr.responseText ? JSON.parse(xhr.responseText) : {}))
} catch (error) {
reject(error)
}
}
xhr.onerror = () => reject(new Error('Network error'))
xhr.onabort = () => reject(new DOMException('Upload cancelled', 'AbortError'))
signal.addEventListener('abort', () => xhr.abort())
const body = new FormData()
body.append(field, file)
xhr.send(method === 'PUT' ? file : body)
})
}