Interactive demo of file upload fields
/**
* File Field Demo - Interactive example of file upload fields
*/
import { Form, FormBuilder } from '@saastro/forms';
import { FormProvider } from '@/components/FormProvider';
import { TooltipProvider } from '@/components/ui/tooltip';
const config = FormBuilder.create('file-demo')
.layout('manual')
.columns(12)
.addField('resume', (f) =>
f
.type('file')
.label('Resume')
.accept('.pdf,.doc,.docx')
.helperText('A single document — PDF or Word')
.columns({ default: 12, md: 6 })
.optional(),
)
.addField('photos', (f) =>
f
.type('file')
.label('Photos')
.accept('image/*')
.multiple(true)
.helperText('Select one or more images')
.columns({ default: 12, md: 6 })
.optional(),
)
.addStep('main', ['resume', 'photos'])
.build();
export default function FileDemo() {
const handleSubmit = (data: Record<string, unknown>) => {
const resume = data.resume as File | null;
const photos = (data.photos as File[] | null) ?? [];
console.log('Form submitted:', {
resume: resume ? { name: resume.name, size: resume.size, type: resume.type } : null,
photos: photos.map((file) => ({ name: file.name, size: file.size, type: file.type })),
});
alert('Form submitted! Check console for the selected files.');
};
return (
<TooltipProvider>
<FormProvider>
<Form config={config} onSubmit={handleSubmit} className="space-y-4" />
</FormProvider>
</TooltipProvider>
);
} Overview
The file field renders a native <input type="file"> styled to match the rest of the form. After selection, the file name(s) are listed below the input.
The field value holds real File objects:
- Single mode (default):
File | null—nulluntil the user picks a file, and again if they clear the selection. - With
multiple: true:File[]— an empty array if the selection is cleared.
Because the value is a File, it is not JSON-serializable — see Submitting files for how files travel in the submit pipeline.
Usage
FormBuilder API
import { FormBuilder } from '@saastro/forms';
const config = FormBuilder.create('application')
.addField('resume', (f) =>
f
.type('file')
.label('Resume')
.accept('.pdf,.doc,.docx')
.helperText('PDF or Word document'),
)
.addField('photos', (f) =>
f
.type('file')
.label('Photos')
.accept('image/*')
.multiple(true),
)
.addStep('main', ['resume', 'photos'])
.build();
JSON Configuration
{
"type": "file",
"label": "Resume",
"accept": ".pdf,.doc,.docx",
"multiple": false,
"helperText": "PDF or Word document"
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
type | 'file' | - | Field type (required) |
accept | string | - | Accepted file types, passed to the native accept attribute (e.g. "image/*", ".pdf,.doc") |
multiple | boolean | false | Allow selecting multiple files; changes the value shape to File[] |
maxSize | number | - | Max file size in bytes — validated client-side on submit (shows a field error). Validate server-side too |
label | string | - | Field label |
helperText | string | - | Help text shown below the field |
columns | Partial<Record<Breakpoint, number>> | - | Grid columns by breakpoint |
disabled | boolean | function | ConditionGroup | false | Disable the input |
hidden | boolean | function | ConditionGroup | Responsive | false | Hide the field |
readOnly | boolean | function | ConditionGroup | false | Ignore file selection changes |
acceptis a browser hint, not validation. It filters the file picker dialog, but users can still drop or select other types in some browsers. Checkfile.typein your submit handler if the type matters.
Validation
File fields bypass schema validation: the library applies a pass-through (z.any()) schema to file fields, so .required(), .schema(...), and JSON validation rules have no effect on them. The one exception is maxSize — when set, oversized files produce a field error on submit.
If a file is mandatory, check the value in your submit handler:
const handleSubmit = (data: Record<string, unknown>) => {
const resume = data.resume as File | null;
if (!resume) {
alert('Please attach your resume');
return;
}
if (resume.size > 5 * 1024 * 1024) {
alert('File must be under 5 MB');
return;
}
// ... send the data
};
Submitting Files
File values cannot be serialized to JSON, so how they reach your backend depends on the submit path:
- Hosted backend (
HubForm) — file fields are the attachment mechanism for hosted uploads. On submit,Filevalues are uploaded directly to storage via presigned URLs and the submission carries them as anattachmentsarray ({ key, filename, size, mime }). See the HubForm guide. - HTTP submit actions — use
format: 'form-data';Filevalues are appended natively to theFormDatabody. See the Submit guide. - Custom
onSubmithandler — you receive the rawFile/File[]values and can upload them however you like.
Upload on select (UploadAdapter)
For your own backend (R2 / S3 / anything), give the field an uploadAdapter. Files then upload as soon as they’re selected, with a progress bar, and the field value becomes the adapter’s result (e.g. { key } or { url }) instead of the raw File.
createPresignedUploadAdapter generalises the presigned-URL pipeline: you provide getUploadTarget (ask your backend for a short-lived upload URL), and the adapter PUTs the binary straight to it with real progress.
import { createPresignedUploadAdapter } from '@saastro/forms';
const uploadAdapter = createPresignedUploadAdapter({
getUploadTarget: async (file) => {
const r = await fetch('/api/upload-url', {
method: 'POST',
body: JSON.stringify({ name: file.name, type: file.type, size: file.size }),
}).then((res) => res.json());
return {
uploadUrl: r.uploadUrl, // presigned PUT URL
headers: { 'Content-Type': file.type },
result: { key: r.key }, // what gets stored as the field value
};
},
});
.addField('cv', (f) => f.type('file').label('CV').accept('.pdf').prop('uploadAdapter', uploadAdapter))
uploadAdapter is a function (not serializable), so it’s for code-defined forms. Without it the field keeps its default behaviour (stores raw Files, uploaded on submit). The useFileUpload hook and UploadAdapter type are also exported if you want a fully custom file UI.
Styling
The input uses built-in Tailwind classes (h-10, rounded border, muted file-button text). Extend or override them per part:
.addField('resume', (f) =>
f.type('file')
.label('Resume')
.accept('.pdf')
.classNames({
wrapper: 'bg-muted/50 p-4 rounded-lg',
input: 'border-primary',
label: 'font-medium',
})
)
Responsive Layout
.addField('photos', (f) =>
f.type('file')
.label('Photos')
.accept('image/*')
.multiple(true)
.columns({ default: 12, md: 6 })
)
Related
- HubForm - Hosted submit backend with file attachments
- Submit - Submit actions, including
form-dataHTTP requests - Hidden - Another special field with a pass-through schema
Accessibility
- Uses the native
<input type="file">element for built-in keyboard and screen reader support - Label is associated with the input via the field wrapper
- Selected file names are displayed as visible text below the input
- Error messages are linked via
aria-describedby