Form

Form state, validation, and submit handling for Corn UI inputs.

import { ... } from '@corndev/ui'

HTML

<form class="corn-form">
  <div class="corn-form-field">
    <label class="corn-label" for="email">Email</label>
    <input id="email" name="email" class="corn-input" />
    <span class="corn-form-error" data-error-for="email"></span>
  </div>
</form>

Classes

Class
.corn-form
.corn-label
.corn-form-field
.corn-form-description
.corn-form-error

Attributes

OnAttributeValues
.corn-form-fielddata-layoutinline
.corn-form-errordata-visiblepresent or absent

Vanilla JS

Required for behaviour — adds reads and validates field values, shows messages in the [data-error-for] slots, and links each field to its message with aria-describedby.

import { createForm } from '@corndev/ui';
import '@corndev/ui/theme';

const formEl = document.querySelector('#target');
const instance = createForm(formEl, { /* options */ });

Options

OptionTypeDefaultDescription
initialValuesObject.<string, any>{}Initial value for each field, keyed by name.
validate(values: Object.<string, any>) => FormErrorsundefinedReturns a { field: message } map for invalid fields (empty object = valid).
onSubmit(values: Object.<string, any>) => voidundefinedCalled with the current values when the form submits and validation passes.
validateOnBlurbooleantrueRe-run validation when a field loses focus.
validateOnChangebooleanfalseRe-run validation on every field change.

React

Form, useForm, useField, FieldError — from @corndev/ui-react.

children may be a render function receiving the form context. useField(name, { describedBy }) returns { name, value, error, onChange, onBlur, aria-invalid, aria-describedby }; spread it onto an input. While a field has an error it is marked aria-invalid, and if a <FieldError name> is rendered, aria-describedby points at that message. Pass your own description ids as describedBy rather than an aria-describedby prop, which the spread would replace. useForm() returns the form context (values, errors, touched, setFieldValue, setFieldTouched, errorId, …) and throws if called outside a <Form>.

The props are the same as the options above, plus className.

<FieldError> props

Shows a field’s validation message and links it to the field with aria-describedby.

PropTypeDefaultDescription
namestringrequiredName of the field whose error this shows.
classNamestringundefinedMerged with the Corn UI classes.

Form tracks field values, runs validation, and handles submission so you don’t need a separate forms library. Fields are bound by name. In React, use the useField(name) hook to wire a field’s value, onChange, onBlur, and error; reach the whole form state with useForm().

React

import { Form, useField, Field, Input, Button } from '@corndev/ui-react';

function EmailField() {
  const { error, ...input } = useField('email');
  return (
    <Field label="Email" error={error} required>
      <Input type="email" {...input} />
    </Field>
  );
}

function SignIn() {
  return (
    <Form
      initialValues={{ email: '' }}
      validate={(v) => (v.email ? {} : { email: 'Required' })}
      onSubmit={(values) => console.log(values)}
    >
      <EmailField />
      <Button type="submit">Sign in</Button>
    </Form>
  );
}

Field draws the label, description and error, and wires id, aria-describedby and aria-invalid for you, so pass it the error and spread the rest onto the control.

Without Field, useField still marks the control aria-invalid while it has an error. <FieldError name> shows the message, and while it’s rendered the field’s aria-describedby points at it, so screen readers announce why the field is invalid. If the field has its own description, such as a hint, pass that id as useField(name, { describedBy: 'email-hint' }) rather than as an aria-describedby prop, which the spread would replace.

Vanilla JS

Discovers fields by name. Add an element with data-error-for="{name}" to display a field’s error message. While the error shows, the field is marked aria-invalid and linked to that element with aria-describedby.

<form id="signin">
  <label for="email">Email</label>
  <input id="email" name="email" />
  <span data-error-for="email" class="corn-form-error"></span>
  <button type="submit">Sign in</button>
</form>
import { createForm } from '@corndev/ui';

const form = createForm(document.querySelector('#signin'), {
  initialValues: { email: '' },
  validate: (values) => (values.email ? {} : { email: 'Required' }),
  onSubmit: (values) => console.log(values),
});

Field — the label, the control and the wiring between them

A form is mostly fields, and a field is mostly wiring: the control needs an id, the label a matching htmlFor, and the description and error have to be named in aria-describedby.

Getting that wrong is silent. An unassociated label renders exactly like an associated one — nothing looks broken, the field simply has no name when a screen reader reaches it. Field owns that wiring, so each call site cannot get it wrong separately.

import { Field, Input } from '@corndev/ui-react';

<Field label="Client secret" description="Kept in the keychain" error={errors.secret}>
  <Input type="password" value={secret} onChange={onChange} />
</Field>

The control is a child, not a prop

Deliberately. A Field that rendered the control itself would have to forward value, onChange, onBlur, name, defaultValue, placeholder and everything after them — and would need a variant per control type. As a child, the same Field works for anything:

<Field label="Format"><Select options={formats} /></Field>
<Field label="Notes"><Textarea rows={4} /></Field>
<Field label="Include examples"><Checkbox /></Field>

It is presentational

Field does not own value or validation state, so it suits a field driven by useForm and one driven by a parsed document equally — and in a document editor most fields are the second kind. The two compose:

const field = useField('host');

<Field label="Host" error={field.error}>
  <Input {...field} />
</Field>

Layout

It renders one wrapper, .corn-form-field: a flex column with a small gap, carrying min-inline-size: 0 — so a field inside a Fieldset inside a flex column shrinks rather than pushing out of the box.

Vanilla

createField wires markup you already have rather than building any:

<div>
  <label>Client secret</label>
  <input type="password" />
  <p data-field-description>Kept in the keychain</p>
  <p data-field-error></p>
</div>
import { createField } from '@corndev/ui';

const field = createField(el);
field.update({ error: 'Required' });