@privaty/ui-forms
v0.7.1
Published
Readme
@privaty/ui-forms
Form components built on SvelteKit remote functions (experimental):
Form wraps a form(schema, handler) instance, inputs bind to its fields,
and a small FormState layer adds the display semantics Kit doesn't ship —
value-based dirty tracking, touch-gated error display, submit gating.
pnpm add @privaty/ui @privaty/ui-formsRequires
@privaty/uias a peerDependency at the same lockstep version (single instance — Symbol-keyed contexts),@sveltejs/kitwithexperimental.remoteFunctions+compilerOptions.experimental.async, and Tailwind v4 (@sourcethe package, see the core README).
Everything imports from the package root — import { Form, TextInput,
Submit } from "@privaty/ui-forms" — which tree-shakes; there are no deep
subpaths (as of 0.6.0). The testing fakes live behind their own barrel,
@privaty/ui-forms/testing.
Quickstart
<script>
import { Form, FormError, Submit, TextInput } from "@privaty/ui-forms";
import { createItem } from "./data.remote";
import { createItemSchema } from "./schema";
</script>
<Form form={createItem} schema={createItemSchema}>
<TextInput field={createItem.fields.name} label="Name" required />
<FormError />
<Submit />
</Form>Inputs: TextInput (text/email/password/search/url/tel), TextareaInput,
NumberInput, DateInput (date/month/week/time/datetime-local — one
component, all string-valued), SelectInput, CheckboxInput. Plus
Submit, Reset, FormError. Icon-style Submit/Reset: pass children; the
label stays as the accessible name. HiddenInput carries a value the user
never edits (a row id, a parent record's id) — context-free, no dirty
tracking or marker, works outside a <Form> too.
Picker inputs
DatePickerInput, MonthPickerInput, and WeekPickerInput pair the
native input with the core library's cross-browser calendar pickers — the
custom month/week UIs Firefox never got, and one consistent picker
everywhere else.
- The native input stays visible as the FormData carrier: typing works, native mobile pickers stay, and Kit reads live form data as ever. Firefox renders month/week carriers as plain text inputs — exactly the gap the calendar button fills.
- Firefox date exception: Firefox draws an unhideable calendar icon on
type="date"(Bugzilla 1830890), soDatePickerInputyields there — the native icon opens Firefox's own picker and our trigger hides, keeping one icon in every browser. Month/week use our picker everywhere. - The overlaid calendar button opens the picker in an anchored
Popover(top layer, light dismiss, zero-lag scroll tracking). A pick writes the input the way typing does (DOM value + a bubblinginputevent), so validation cadence, touch marking, and dirty tracking are identical to manual entry — one write path, no programmatic special case. min/maxflow to both the native input and the picker, andlocaleis on all three;DatePickerInputaddsisDateDisabled,showWeekNumbers, andfirstDayOfWeek(all picker-side — constraints still belong in the schema, typing can produce anything).- The trigger's accessible name comes from
labels.calendar.open; while the form submits, the input turns readonly and the trigger disables.
The validation model
- With
schema(a Standard Schema, e.g. valibot): validation runs client-side via Kit'spreflight— immediately on input, and on submit. Kit swallows invalid submits before the enhance callback runs; the Form's own submit listener still opens the error gates, so a rejected click shows its issues. - Without
schema: every validation is a server round-trip — typing is debounced (validationDebounce, default 400 ms), submits validate server-side. Transform schemas (Output ≠ Input) are accepted. - Issues per field appear once the field is touched or a submit was
attempted;
FormErrorshows path-less issues and submit failures (labels.form.generalError). - Server issues (a rejected submission, a server validation round-trip)
are persisted by Kit through every client-side validation pass — no edit
can refresh them, only another round-trip. The Form handles both
consequences: a schema'd resubmission is never gated on them (the
submission re-judges them authoritatively), and while they linger, input
revalidation escalates to full validation — client schema first, then the
server round-trip that replaces the whole issue set — debounced like
schema-less typing. Rules that depend on server data (a cross-field cap, a
uniqueness check) therefore belong in the server schema (an async check
is fine) if they should refresh live while the user edits; issues raised in
the handler (
invalid()) clear optimistically on the next round-trip and are re-judged at submit.
Schema recipes (the footguns)
// Checkboxes: unchecked submits NOTHING — the schema supplies the false.
inStock: v.optional(v.boolean(), false),
// REQUIRED placeholder selects: the disabled prompt is skipped by
// submission — default "" so YOUR message fires instead of a raw
// missing-key error. (Optional selects are clearable instead: the empty
// pick SUBMITS "", so a plain v.optional(v.string()) receives it.)
category: v.pipe(v.optional(v.string(), ""), v.picklist(categories, "required")),
// Optional selects, when the handler prefers undefined over "": FormData
// cannot carry an absent value from an enabled option, so normalize at
// the schema boundary — the designed seam for exactly this.
categoryId: v.optional(v.pipe(v.string(), v.transform((value) => value || undefined))),
// Month inputs submit "YYYY-MM"; empty submits "". Two v.check actions,
// not nonEmpty+regex: valibot pipes run every action even after a failure,
// so an empty value would show BOTH messages stacked.
availableFrom: v.pipe(
v.string(),
v.check((value) => value.length > 0, "required"),
v.check((value) => value === "" || /^\d{4}-\d{2}$/.test(value), "invalid-month"),
),One form object, one <form> element
Kit enforces it at attach time: "A form object can only be attached to a
single <form> element. To create multiple instances, use
name.for(key)." The singleton returned by form(schema, handler) can
back exactly one mounted <Form> — a second mount anywhere on the page
throws.
The trap is REPEATED content: a create-form modal inside a table's
expanded snippet renders once per expanded row, and a closed <dialog>
still MOUNTS its children — so expanding a second row crashes before any
modal is opened. Key the instance per repetition instead:
{#snippet expandedContent({ row })}
{@const bulkForm = bulkCreateMonths.for(row.id)}
<Modal title="Bulk create">
<Form form={bulkForm} schema={bulkCreateSchema}>
<!-- The key is client-side instance identity only — it is NOT
submitted. Parent linkage still rides along explicitly. -->
<HiddenInput field={bulkForm.fields.allocationId} value={row.id} />
...
</Form>
</Modal>
{/snippet}<Form> accepts a .for() instance as-is (the tables' per-row edit
forms are exactly this), and each key gets independent draft and issue
state — two open modals never share a half-typed value.
Every input's field must come off the SAME keyed instance — hold
it in one {@const} and read fields from it, never from the
singleton. Each instance suffixes its field names with its own id, so
singleton fields inside a keyed <Form> are invisible to it: edits
sync nowhere, dirty never trips, Submit/Reset stay disabled, and a
forced submit throws "Form contained a field that wasn't created with
form.fields.as(...)".
Rules learned the hard way
- Never disable controls while submitting — disabled controls are
excluded from
FormData, and Kit reads live form data mid-submission. The inputs lock viareadonly/ pointer+keyboard locks instead. Keep that contract in custom inputs. - Queries:
await getItems()bare.query().currentnever server-renders (hard-codedundefinedon the server), and a<svelte:boundary>with apendingsnippet makes the server render the snippet INSTEAD of the children. Await, no pending boundary. - Required/optional markers follow the majority rule (the minority gets
marked) and wait for
FormState.settled— they cannot be SSR'd with self-registering fields. - No-JS submissions are out of scope for v1 (Submit renders its gate state into SSR HTML).
- Browser support: Firefox has no
type="month"/"week"pickers (falls back to a text input) — the picker inputs above fill exactly that gap.
Testing
@privaty/ui-forms/testing ships Kit-faithful fakes: fakeRemoteForm
preflight-gates its enhance callback like Kit does, and the field fakes'
edit() stores raw DOM values ("5", "on") while set() stores typed
ones — mirroring Kit's mid-edit behavior so dirty-tracking tests mean
something.
