Skip to content

Form ​

A form screen is four decisions stacked. Form owns validation for everything inside it, Fieldset draws the boundary round a section, FieldGroup stacks the fields, and each field owns its own label, description and error. Nothing above a field knows what kind of control it holds, which is why a Select and a TextArea sit in the same stack without being special-cased.

ts
import {
  FieldGroup,
  Fieldset,
  FieldsetActions,
  FieldsetLegend,
  Form,
  Select,
  TextArea,
  TextField,
} from "ropav";
WorkspaceEveryone you invite lands here.
Appears in every link to this workspace.
Plan
form.vue
vue
<script setup lang="ts">
import {
  Button,
  Checkbox,
  CheckboxContent,
  CheckboxControl,
  CheckboxIndicator,
  Description,
  FieldError,
  FieldGroup,
  Fieldset,
  FieldsetActions,
  FieldsetLegend,
  Form,
  Input,
  Label,
  ListBox,
  ListBoxItem,
  ListBoxItemIndicator,
  Select,
  SelectIndicator,
  SelectPopover,
  SelectTrigger,
  SelectValue,
  TextArea,
  TextField,
} from "ropav";
import { shallowRef } from "vue";

const plans = [
  { id: "starter", name: "Starter" },
  { id: "team", name: "Team" },
  { id: "enterprise", name: "Enterprise" },
];

const byName = (plan: { name: string }) => plan.name;

const serverErrors = shallowRef<Record<string, string>>({});

const validateSlug = (value: string) =>
  /^[a-z0-9-]*$/.test(value) ? null : "Lowercase letters, numbers and dashes only.";

const onSubmit = (event: Event) => {
  event.preventDefault();

  const data = new FormData(event.target as HTMLFormElement);

  // A fresh object each time, so the same field rejected twice reads as a new response.
  serverErrors.value = data.get("slug") === "acme" ? { slug: "That URL is taken." } : {};
};
</script>

<template>
  <Form
    class="form"
    validation-behavior="aria"
    :validation-errors="serverErrors"
    @submit="onSubmit"
  >
    <Fieldset>
      <FieldsetLegend>Workspace</FieldsetLegend>
      <Description>Everyone you invite lands here.</Description>

      <FieldGroup>
        <TextField is-required name="name">
          <Label>Name</Label>
          <Input placeholder="Acme Research" />
          <FieldError />
        </TextField>

        <TextField default-value="acme" is-required name="slug" :validate="validateSlug">
          <Label>URL</Label>
          <Input />
          <Description>Appears in every link to this workspace.</Description>
          <FieldError />
        </TextField>

        <Select
          full-width
          is-required
          :item-text-value="byName"
          :items="plans"
          name="plan"
          placeholder="Choose a plan"
        >
          <Label>Plan</Label>
          <SelectTrigger>
            <SelectValue />
            <SelectIndicator />
          </SelectTrigger>
          <SelectPopover>
            <ListBox>
              <ListBoxItem
                v-for="plan in plans"
                :id="plan.id"
                :key="plan.id"
                :text-value="plan.name"
              >
                {{ plan.name }}
                <ListBoxItemIndicator />
              </ListBoxItem>
            </ListBox>
          </SelectPopover>
          <FieldError />
        </Select>

        <TextField name="notes">
          <Label>Notes</Label>
          <TextArea full-width placeholder="Anything the team should know." rows="3" />
        </TextField>

        <Checkbox name="digest" value="weekly">
          <CheckboxContent>
            <CheckboxControl>
              <CheckboxIndicator />
            </CheckboxControl>
            Send a weekly digest
          </CheckboxContent>
        </Checkbox>
      </FieldGroup>

      <FieldsetActions>
        <Button type="reset" variant="secondary">Cancel</Button>
        <Button type="submit">Save</Button>
      </FieldsetActions>
    </Fieldset>
  </Form>
</template>

<style scoped>
.form {
  max-width: var(--rp-container-md);
}
</style>

What Form contributes ​

Form renders a <form> and provides two things to every field under it: the validation behaviour, and the errors a server sent back. It holds no values — the fields do, and submission is a plain FormData read off the event.

The two kinds of error ​

validate is the client's: it runs against the field's own value and reports a message. validation-errors is the server's — an object keyed by the name each field submits under, which a field shows until the value is edited. Assign a fresh object for each response, or the same field rejected twice reads as no change at all.

Submit the form above without touching it to see one land.

Native or ARIA ​

validation-behavior defaults to "native": the browser blocks submission and the errors appear on a failed submit. "aria" marks the fields through ARIA, reveals errors as the value changes, and leaves submission alone — which is what you want when submitting is a fetch rather than a navigation. is-required holds either way — the browser enforces it under "native", and under "aria" the field reaches the same verdict itself, kept back until the first submit.

Accessibility ​

  • FieldsetLegend is read before every field inside it, so it should be short — "Workspace" prefixed onto "Name" reads well; a sentence does not.
  • Each field carries its own FieldError. A summary at the top of a form is not a substitute: the message has to be reachable from the control it belongs to.
  • The actions are last in the DOM as well as last on screen, so the tab order and the reading order agree.