component

Field

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>

Basic

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>

Required & label tooltip

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>

Validation feedback

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>

Sizes

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.

(optional)
(optional)
(optional)
(optional)
---
// 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>

Horizontal layout

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>

Control as a child (React)

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>
  );
}

Forms with TanStack Form

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>
  • Components
  • Blocks