block
Type-safe forms wired to Pindoba controls
A thin integration layer that wires TanStack Form
to Pindoba controls. It builds on the presentational Field
wrapper — Field owns the label, description, tooltip, error region, accessibility
wiring, and validation-driven feedback color; this block adds the form state,
validation, and value binding on top.
It ships ready-made <FormInput>, <FormSelect>, and <FormCheckbox> wrappers
for the common controls, a generic <FormField> escape hatch for everything
else, and a <FormSubmit> button that disables while a submission is in flight
(and, opt-in, while the form is invalid). The package re-exports TanStack’s
createForm, so you import everything from one place.
Available for Svelte, React, and Astro. All build on the same
agnostic core (@pindoba/core-tanstack-form) over @tanstack/form-core;
the wrappers and error/feedback behavior are identical.
They differ only in how you create the form, because TanStack Form is client-reactive and Astro has no runtime reactivity:
createForm(...) (re-exported from the block) and pass the
instance to each wrapper via form={...}.useForm(...) (re-exported from the block) and pass the instance
to each wrapper via form={...}.<pindoba-form> and create the form in a
client <script> (new FormApi(...)), then assign it to the element’s
.form property. Validators and onSubmit aren’t serializable, so the form
definition lives in script, not markup. The custom element wires every field
via bindForm and re-binds across view transitions automatically. (Prefer the
lower-level bindForm(rootEl, form) directly if you’re not using the element.)createForm / new FormApi accept Standard Schema
validators natively — Zod 3.24+, Valibot, and ArkType all work without any
adapter. Pass one under validators.onChange (or onBlur / onSubmit) and the
matching field messages flow straight into each control’s error region. Toggle
the demo’s Code view for the full source — including the Astro client
<script> that builds the form.
---
import { stack } from "@pindoba/styled-system/patterns";
import FormInput from "../FormInput.astro";
import FormSubmit from "../FormSubmit.astro";
---
<pindoba-form id="tsf-basic">
<div class={stack({ gap: "md", direction: "column" })}>
<FormInput name="name" label="Name" required />
<FormInput name="email" label="Email" type="email" required />
<FormSubmit>Create account</FormSubmit>
<p data-form-result hidden></p>
</div>
</pindoba-form>
<script>
import "../pindoba-form";
import type { PindobaFormElement } from "../pindoba-form";
import { FormApi } from "@tanstack/form-core";
import { z } from "zod";
// `@tanstack/form-core` accepts Standard Schema validators natively — Zod
// 3.24+, Valibot, ArkType all work with no adapter.
const schema = z.object({
name: z.string().min(2, "Use at least 2 characters."),
email: z.string().email("Enter a valid email address."),
});
const root = document.querySelector<PindobaFormElement>("#tsf-basic");
const result = root?.querySelector<HTMLElement>("[data-form-result]");
const form = new FormApi({
defaultValues: { name: "", email: "" },
validators: { onChange: schema },
onSubmit: async ({ value }) => {
if (result) {
result.textContent = `Submitted: ${JSON.stringify(value)}`;
result.hidden = false;
}
},
});
form.mount();
if (root) root.form = form;
</script><FormInput>, <FormSelect>, and <FormCheckbox> each bind a control to a form
field and forward every Field prop (label, description, required,
labelTooltip, size, orientation, feedback, errorMode, …). Errors flip
the control to the danger feedback color automatically.
---
import { stack } from "@pindoba/styled-system/patterns";
import FormInput from "../FormInput.astro";
import FormSelect from "../FormSelect.astro";
import FormCheckbox from "../FormCheckbox.astro";
import FormSubmit from "../FormSubmit.astro";
const countries = [
{ id: "br", label: "Brazil" },
{ id: "us", label: "United States" },
{ id: "pt", label: "Portugal" },
];
---
<pindoba-form id="tsf-mixed">
<div class={stack({ gap: "md", direction: "column" })}>
<FormInput name="fullName" label="Full name" required />
<FormSelect
name="country"
label="Country"
items={countries}
placeholder="Select a country"
required
/>
<FormCheckbox name="terms" label="Agreement" errorMode="submitted">
I accept the terms of service
</FormCheckbox>
<FormSubmit>Continue</FormSubmit>
<p data-form-result hidden></p>
</div>
</pindoba-form>
<script>
import "../pindoba-form";
import type { PindobaFormElement } from "../pindoba-form";
import { FormApi } from "@tanstack/form-core";
import { z } from "zod";
const schema = z.object({
fullName: z.string().min(2, "Use at least 2 characters."),
country: z.string().min(1, "Pick a country."),
terms: z.literal(true, { message: "You must accept the terms." }),
});
const root = document.querySelector<PindobaFormElement>("#tsf-mixed");
const result = root?.querySelector<HTMLElement>("[data-form-result]");
const form = new FormApi({
defaultValues: { fullName: "", country: "", terms: false },
validators: { onChange: schema },
onSubmit: async ({ value }) => {
if (result) {
result.textContent = `Submitted: ${JSON.stringify(value)}`;
result.hidden = false;
}
},
});
form.mount();
if (root) root.form = form;
</script>By default a field’s errors appear only after it has been touched. Use
errorMode to change that:
"touched" (default) — after the user interacts with the field."submitted" — only after a submit attempt."always" — as soon as the value is invalid.<!-- Svelte -->
<FormCheckbox {form} name="terms" label="Agreement" errorMode="submitted">
I accept the terms of service
</FormCheckbox>
<!-- Astro — same prop; the form lives in a client <script> -->
<FormCheckbox name="terms" label="Agreement" errorMode="submitted">
I accept the terms of service
</FormCheckbox>
For any control without a convenience wrapper (combobox, date picker, range,
your own component), the generic <FormField> renders the field chrome and
leaves the control to you.
control(props, field) snippet, so you wire value and handlers
yourself.bindForm auto-wires any
standard pindoba control it finds (input, checkbox, Select). Match the
control’s id to the field with the exported fieldId(name) helper so the
label associates natively.Toggle the demo’s Code view to see the pattern.
Your public @handle.
---
import { InputRoot, InputField } from "@pindoba/astro-input";
import Affix from "@pindoba/astro-affix";
import { stack } from "@pindoba/styled-system/patterns";
import FormField from "../FormField.astro";
import { fieldId } from "../field-id";
// The generic <FormField> renders the field chrome (label, description, error
// region, a11y) and leaves the control to you — drop in any pindoba control and
// `bindForm` auto-wires it. Here input parts with a leading "@" Affix prefix
// that <FormInput> doesn't expose. Match the control id to the field id so the
// label associates natively.
---
<pindoba-form id="tsf-generic">
<div class={stack({ gap: "md", direction: "column" })}>
<FormField
name="handle"
label="Username"
description="Your public @handle."
>
<InputRoot>
<Affix decorative>@</Affix>
<InputField id={fieldId("handle")} name="handle" />
</InputRoot>
</FormField>
</div>
</pindoba-form>
<script>
import "../pindoba-form";
import type { PindobaFormElement } from "../pindoba-form";
import { FormApi } from "@tanstack/form-core";
import { z } from "zod";
const schema = z.object({
handle: z
.string()
.regex(/^@?[a-z0-9_]{2,}$/i, "Letters, numbers and underscores only."),
});
const root = document.querySelector<PindobaFormElement>("#tsf-generic");
const form = new FormApi({
defaultValues: { handle: "" },
validators: { onChange: schema },
onSubmit: async () => {},
});
form.mount();
if (root) root.form = form;
</script><FormSubmit> subscribes to the form’s submit-readiness and renders a Pindoba
<Button> that disables while a submission is in flight, setting aria-busy
during submit. By default it stays clickable when the form is invalid (so
submitting surfaces the errors); set disableWhenInvalid to also disable it
while invalid.
<!-- Svelte -->
<FormSubmit {form} emphasis="primary">Save changes</FormSubmit>
<!-- Astro — no form prop; it's bound by the surrounding <pindoba-form> -->
<FormSubmit emphasis="primary" disableWhenInvalid>Save changes</FormSubmit>
createForm, re-exported from @tanstack/svelte-form. See the
Svelte adapter docs.FormApi, re-exported from @tanstack/form-core. Build it in a
client <script>, form.mount(), then assign to <pindoba-form>’s .form
property (or call bindForm(rootEl, form)).Both take the same option set (defaultValues, validators, onSubmit, async
validation, field arrays, …).
The reference below documents three surfaces:
<FormField>), in addition to name. In Svelte you also
pass the form instance via form={...}; in Astro the form is bound by
the surrounding <pindoba-form>, so the wrappers take no form prop.<FormInput>, <FormSelect>, <FormCheckbox>); each applies only to the
wrapper named in its description.<FormSubmit> button.Help text rendered under the control and wired via `aria-describedby`.
falseDisables the field and forwards `disabled` to the control.
"touched"When validation errors become visible: after the field is touched, after a submit attempt, or always.
Explicit feedback override; defaults to error-derived `danger`, otherwise the control's own resting color.
Svelte / React only — the TanStack form instance (`createForm()` in Svelte, `useForm()` in React). In Astro the form is bound by the surrounding `<pindoba-form>`, so wrappers take no `form` prop.
Label text shown above (or beside) the control.
Tooltip attached to an info trigger next to the label.
Field name — a key of the form's data.
Optional-hint text after the label when not required (e.g. "(optional)").
"vertical"Label/control layout direction.
Per-slot escape hatch for Field styling and attributes.
falseRenders a required `*` after the label.
"md"Size variant, kept in sync with the control. `FormSelect`/`FormCheckbox` accept `sm`/`md`/`lg` (no `xs`).
falseAstro FormCheckbox only — initial server-rendered checked state.
FormCheckbox — inline text/content beside the checkbox (Svelte snippet / Astro default slot).
FormField only — Svelte / React render-prop for the control. Receives the props bag to spread plus the raw TanStack field API for value/handler binding. (Astro uses the default slot instead; `bindForm` auto-wires standard controls.)
FormSelect only — the options to choose from.
FormInput / FormSelect — placeholder shown when empty.
"single"FormSelect only — `"single"` (default) or `"multiple"`.
"text"FormInput only — HTML input type forwarded to `<Input>`.
Astro only — initial server-rendered value for FormInput (string) / FormSelect. The form is the source of truth after hydration.
Button label content. Falls back to "Submit".
falseAlso disable while the form is invalid (not just while submitting). Off by default, so submitting an invalid form surfaces its errors.
"primary"Emphasis forwarded to the underlying `<Button>`.
Svelte / React only — the TanStack form instance (`createForm()` in Svelte, `useForm()` in React). In Astro the surrounding `<pindoba-form>` binds it.
Size forwarded to the underlying `<Button>`.