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.
import {
FieldGroup,
Fieldset,
FieldsetActions,
FieldsetLegend,
Form,
Select,
TextArea,
TextField,
} from "ropav";form.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
FieldsetLegendis 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.