A form-field wrapper that pairs any Pindoba control with a label, optional
description, required/optional markers, an info tooltip, and validation error
messages — and wires the accessibility between them. Field is presentational and
framework-state-agnostic: you pass it a label and an error, and it derives the
control’s tone color, links label ↔ control, and exposes the right
aria-* attributes. For full form state and validation, pair it with
TanStack Form.
The control is rendered through a control snippet that receives a props bag to
spread onto the control:
<Field label="Email" description="We'll send your receipt here." required>
{#snippet control(props)}
<Input {...props} bind:value type="email" />
{/snippet}
</Field>
A label and a help description, wired to the control via for /
aria-describedby.
We'll send your receipt here.
---
// GENERATED from packages/demos/field/default.demo.tsx — edit the source, not this file.
import Field from "@pindoba/astro-field";
import Input from "@pindoba/astro-input";
---
<Field label="Email" description="We'll send your receipt here.">
<Input type="email" placeholder="you@example.com" />
</Field>Mark a field required to render a *, and attach a labelTooltip for an
inline info trigger next to the label.
---
// GENERATED from packages/demos/field/required-tooltip.demo.tsx — edit the source, not this file.
import Field from "@pindoba/astro-field";
import Input from "@pindoba/astro-input";
---
<Field
label="API key"
required
labelTooltip="Find this in Settings → Developer. We never store it in plaintext."
>
<Input placeholder="sk-…" />
</Field>Pass an error (string or string array). Field renders the messages as a
list inside a polite alert live region (so they’re announced when they
appear, while the list keeps its list semantics), sets aria-invalid +
aria-errormessage, and flips the control’s tone to danger
automatically.
Type an @ to clear the error.
---
// GENERATED from packages/demos/field/error.demo.tsx — edit the source, not this file.
import Field from "@pindoba/astro-field";
import Input from "@pindoba/astro-input";
const value = "nope";
const error = value.includes("@") ? undefined : "Enter a valid email address.";
---
<Field
label="Email"
description="Type an @ to clear the error."
error={error}
data-ir-root="field/error"
data-ir="1"
>
<Input value="nope" type="email" data-ir="2" />
</Field>
<script>
import { hydrate } from "./_client/error.ts";
document
.querySelectorAll('[data-ir-root="field/error"]')
.forEach((root) => hydrate([root]));
</script>size scales the label, description, and error text. Keep it in sync with the
control’s own size. Use optionalText for an “(optional)” hint when a field
isn’t required.
---
// GENERATED from packages/demos/field/sizes.demo.tsx — edit the source, not this file.
import Field from "@pindoba/astro-field";
import Input from "@pindoba/astro-input";
import { stack } from "@pindoba/styled-system/patterns";
const sizes = ["xs", "sm", "md", "lg"] as const;
---
<div class={stack({ gap: "md", direction: "column" })}>
{sizes.map((size) => (
<Field label={`Size ${size}`} size={size} optionalText="(optional)">
<Input size={size} placeholder={size} />
</Field>
))}
</div>Set orientation="horizontal" to place the label beside the control.
Shown publicly.
---
// GENERATED from packages/demos/field/horizontal.demo.tsx — edit the source, not this file.
import Field from "@pindoba/astro-field";
import Input from "@pindoba/astro-input";
---
<Field
label="Display name"
orientation="horizontal"
description="Shown publicly."
>
<Input placeholder="Ada Lovelace" />
</Field>In React you can skip the control render-prop and pass the control as the
field’s single child. Field injects its controlProps (id, tone, and the
aria-labelledby / aria-describedby / aria-invalid / aria-errormessage
wiring) into that element, so there’s no inline component for lint rules such
as react/no-unstable-nested-components to flag. It works with any control —
Input, SelectNative, InputDatetime, Textarea:
<Field label="Country" error={errors.country}>
<SelectNative name="country" autoComplete="country" items={countries} />
</Field>
The child’s own props win over the field’s wiring, except aria-describedby,
which is merged. If you pass both, control wins. Svelte snippets, Vue scoped
slots and Astro slots don’t have this lint problem, so they keep the control
form.
Solid can’t inject props into an element that already exists, so there the
same wiring arrives as a function child: <Field label="Username">{(props) => <Input {...props} />}</Field>.
Letters and numbers only.
// GENERATED from packages/demos/field/child-control.demo.tsx — edit the source, not this file.
import { useState } from "react";
import { Field } from "@pindoba/react-field";
import { Input } from "@pindoba/react-input";
export default function ChildControlDemo() {
const [value, setValue] = useState("");
return (
<Field
label="Username"
description="Letters and numbers only."
error={value.length > 0 && value.length < 3 ? "Too short." : undefined}
>
<Input
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
autoComplete="username"
/>
</Field>
);
}Field is the presentational half of Pindoba’s forms story. To wire it to real
form state and validation, use the TanStack Form block —
it re-exports createForm and ships ready-made <FormInput>, <FormSelect>,
<FormCheckbox>, a generic <FormField>, and a <FormSubmit> button, with
Standard Schema validation (Zod / Valibot / ArkType).
<script lang="ts">
// GENERATED from packages/demos/tanstack-form/basic.demo.tsx — edit the source, not this file.
import {
FormInput,
FormSubmit,
createForm,
focusInvalidOnSubmit,
} from "@pindoba/svelte-tanstack-form";
import { stack } from "@pindoba/styled-system/patterns";
import { z } from "zod";
const schema = z.object({
name: z.string().min(2, "Use at least 2 characters."),
email: z.string().email("Enter a valid email address."),
});
let submitted = $state(null as string | null);
const form = createForm(() => ({
defaultValues: { name: "", email: "" },
validators: { onChange: schema },
onSubmit: async ({ value }) => {
submitted = JSON.stringify(value);
},
}));
</script>
<form
{@attach focusInvalidOnSubmit(form)}
onsubmit={(event) => {
event.preventDefault();
void form.handleSubmit();
}}
>
<div class={stack({ gap: "md", direction: "column" })}>
<FormInput {form} name="name" label="Name" autocomplete="name" required
></FormInput>
<FormInput
{form}
name="email"
label="Email"
type="email"
autocomplete="email"
required
labelTooltip="We email your receipt here."
></FormInput>
<FormSubmit {form}>Create account</FormSubmit>
{#if submitted}
<p>
Submitted: {submitted}
</p>
{/if}
</div>
</form>