On this pageOverview
File
The File module wraps the browser file APIs as Effects you can run from a Command. It mirrors the design of Elm’s elm/file package: file values are opaque, file selection happens imperatively through a Command (not a form event), and file contents are read asynchronously via FileReader.
A File is a direct alias for the browser’s native File type. You can hold one in your Model with S.Option(File.File). Foldkit never serializes files, so the schema acts as an opaque guard rather than a parser.
File.name, File.size, and File.mimeType return metadata synchronously. File.readAsText, File.readAsDataUrl, and File.readAsArrayBuffer wrap the browser’s FileReader as Effects that can fail with a FileReadError. Use readAsDataUrl when you want a preview thumbnail without uploading the file first.
import { Effect } from 'effect'
import { Command, File } from 'foldkit'
const describeFile = (file: File.File): string =>
`${File.name(file)} (${File.mimeType(file)}, ${File.size(file)} bytes)`
const ReadAvatarPreview = Command.define(
'ReadAvatarPreview',
{ file: File.File },
SucceededReadAvatarPreview,
FailedReadAvatarPreview,
)(({ file }) =>
File.readAsDataUrl(file).pipe(
Effect.map(dataUrl => SucceededReadAvatarPreview({ dataUrl })),
Effect.catch(error =>
Effect.succeed(FailedReadAvatarPreview({ reason: error.reason })),
),
),
)File.select and File.selectMultiple open the native file picker and resolve with what the user chose. Both take a list of accepted MIME types or extensions. File.select resolves with Option.some(file) on a pick or Option.none() on cancel; File.selectMultiple resolves with the array of chosen files, empty if the user cancels. Mirrors Elm’s File.Select.file and File.Select.files.
Wrap the Effect in a Command at the call site with Effect.map to produce your own Message. The File module never defines Messages, so you keep full control of your domain vocabulary.
import { Effect, Option } from 'effect'
import { Command, File } from 'foldkit'
const SelectResume = Command.define(
'SelectResume',
SelectedResume,
CancelledSelectResume,
)(
File.select(['application/pdf']).pipe(
Effect.map(
Option.match({
onNone: () => CancelledSelectResume(),
onSome: file => SelectedResume({ file }),
}),
),
),
)
const SelectAttachments = Command.define(
'SelectAttachments',
SelectedAttachments,
)(
File.selectMultiple(['image/*', 'application/pdf']).pipe(
Effect.map(files => SelectedAttachments({ files })),
),
)For drop zones and inline file pickers, reach for FileDrop. It is a Submodel that wires a drop zone and a hidden <input type="file"> together and emits a ReceivedFiles OutMessage when files arrive (whether dropped or picked through the input). It handles the easy-to-miss details for you: it resets the input so the same file can be picked again, calls preventDefault on drop, and tracks drag state that flips only on true entry and exit. When you need a shape it does not cover, build directly with the OnFileChange and OnDropFiles attributes in foldkit/html.
// Pseudocode walkthrough of the Foldkit integration points. Each labeled
// block below is an excerpt. Fit them into your own Model, init, Message,
// update, and view definitions.
import { Effect, Match as M, Option } from 'effect'
import { Command, File } from 'foldkit'
import { html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { evo } from 'foldkit/struct'
import { FileDrop } from '@foldkit/ui'
// Add the FileDrop Submodel to your Model, plus a list of accepted files:
const Model = S.Struct({
uploader: FileDrop.Model,
uploadedFiles: S.Array(File.File),
// ...your other fields
})
// Initialize both fields:
const init = () => [
{
uploader: FileDrop.init({ id: 'uploader' }),
uploadedFiles: [],
// ...your other fields
},
[],
]
// Embed FileDrop's Message in your parent Message:
const GotFileDropMessage = m('GotFileDropMessage', {
message: FileDrop.Message,
})
// Inside your update function's M.tagsExhaustive({...}), delegate to
// FileDrop.update and pattern-match on the OutMessage it emits when files
// arrive (via drop or input change):
GotFileDropMessage: ({ message }) => {
const [nextUploader, commands, maybeOutMessage] = FileDrop.update(
model.uploader,
message,
)
const nextFiles = Option.match(maybeOutMessage, {
onNone: () => model.uploadedFiles,
onSome: M.type<FileDrop.OutMessage>().pipe(
M.tagsExhaustive({
ReceivedFiles: ({ files }) => [...model.uploadedFiles, ...files],
// Fires when something is dropped but no files came through (e.g.
// a drag of text or a URL). Ignore, or show a hint to the user.
RejectedNonFiles: () => model.uploadedFiles,
}),
),
})
return [
evo(model, {
uploader: () => nextUploader,
uploadedFiles: () => nextFiles,
}),
Command.mapMessages(commands, message => GotFileDropMessage({ message })),
]
}
// Render the drop zone. The `toView` callback receives attribute groups.
// Spread `root` onto a <label> so clicking opens the picker, and spread
// `input` onto a hidden <input type="file"> nested inside. Style the
// drag-over state via `data-drag-over`.
const view = (model: Model) => {
const h = html<Message>()
return h.submodel({
slotId: 'uploader',
model: model.uploader,
view: FileDrop.view,
viewInputs: {
multiple: true,
accept: ['application/pdf', '.doc', '.docx'],
toView: attributes =>
h.label(
[
...attributes.root,
h.Class(
'flex cursor-pointer flex-col items-center gap-2 rounded-xl border-2 border-dashed border-gray-300 p-8 text-center hover:border-accent-400 data-[drag-over]:border-accent-500 data-[drag-over]:bg-accent-50',
),
],
[
h.p([], ['Drop files or click to browse']),
h.span([h.Class('text-sm text-gray-500')], ['PDF, DOC, or DOCX']),
h.input(attributes.input),
],
),
},
toParentMessage: message => GotFileDropMessage({ message }),
})
}Scene tests exercise file flows through two helpers. Scene.dropFiles dispatches a synthetic drop event on a drop zone (e.g. the root of a FileDrop), and Scene.changeFiles dispatches a synthetic change event on a file input. Both accept a target locator and a ReadonlyArray<File>, and throw a clear error if the target element does not have the matching file-event handler registered.
For button-triggered pickers that use the File.select Command, scene tests use Scene.click on the button and then Scene.Command.resolve to synthesize the result, bypassing the native file picker entirely. Use Scene.Command.resolveAll when an update returns multiple Commands at once, or when resolving one Command cascades into others, like reading a preview immediately after a successful selection.
import { Scene } from 'foldkit'
import { describe, test } from 'vitest'
describe('resume upload flow', () => {
const resume = new File(['%PDF-'], 'resume.pdf', {
type: 'application/pdf',
})
test('inline file input: changeFiles simulates selection', () => {
Scene.scene(
{ update, view },
Scene.with(initialModel),
Scene.changeFiles(Scene.label('resume'), [resume]),
Scene.expect(Scene.text('resume.pdf')).toExist(),
)
})
test('button-triggered picker: resolve the SelectResume Command', () => {
const previewDataUrl = 'data:application/pdf;base64,JVBERi0='
Scene.scene(
{ update, view },
Scene.with(initialModel),
Scene.click(Scene.role('button', { name: 'Choose resume' })),
Scene.Command.resolveAll(
[SelectResume, SelectedResume({ file: resume })],
[ReadResumePreview, SucceededReadPreview({ dataUrl: previewDataUrl })],
),
Scene.expect(Scene.role('img', { name: 'Resume preview' })).toExist(),
)
})
test('drop zone: dropFiles simulates a drag-and-drop', () => {
const coverLetter = new File(['cover'], 'cover.txt', {
type: 'text/plain',
})
const portfolio = new File(['<svg/>'], 'portfolio.svg', {
type: 'image/svg+xml',
})
Scene.scene(
{ update, view },
Scene.with(initialModel),
Scene.dropFiles(Scene.label('attachments'), [coverLetter, portfolio]),
Scene.expect(Scene.text('2 attachments selected')).toExist(),
)
})
})