Tessera UI

Components

Form

A form that checks what the person entered before it lets it through. On submit it runs your rules: a valid form fires submit with the values, an invalid one fires invalid with the errors and moves focus to the first field with one.

The preview needs JavaScript. The code is in the Code tab.

Form-0.tess
<script>
const values = state({ email: '', plan: '' })
const errors = state({})
const rules = {
  email: (v) => (/^\S+@\S+\.\S+$/.test(v) ? '' : 'Enter an email such as ada@example.com.'),
  plan: (v) => (v ? '' : 'Choose a plan.'),
}
const plans = [
  { value: 'free', label: 'Free' },
  { value: 'pro', label: 'Pro' },
]

function typed(value) {
  values.value = { ...values.value, email: value }
}
function chose(detail) {
  values.value = { ...values.value, plan: detail.value }
}
function invalid(detail) {
  errors.value = detail.errors
}
function sent() {
  errors.value = {}
}
</script>

<template>
  <Form :values="values" :rules="rules" @invalid="invalid($event)" @submit="sent()">
    <TextField :error="errors.email" @input="typed($event)">
      <span slot="label">Email</span>
    </TextField>
    <RadioGroup :options="plans" :error="errors.plan" @choose="chose($event)">
      <span slot="label">Plan</span>
    </RadioGroup>
    <Button type="submit">Sign up</Button>
  </Form>
</template>

Installation

npx @pixelscriptio/tessera add Form
pnpm exec tessera add Form
yarn tessera add Form
bunx @pixelscriptio/tessera add Form
  • Also installs Button, TextField, which it uses.
  • Works with Tessera 0.9.0 or newer.
  1. Copy and paste the code into your project

    Put it in system/components. Add Button, TextField the same way.

    system/components/Form.tess
    <contract>
    {
      "name": "Form",
      "role": "form",
      "description": "A form that checks what the person entered before it lets it through. On submit it runs your rules: a valid form fires submit with the values, an invalid one fires invalid with the errors and moves focus to the first field with one.",
      "since": "0.8.0",
      "parts": ["root"],
      "slots": {
        "default": "the fields and the buttons"
      },
      "events": {
        "submit": "fired with { values } when the form is submitted and every rule passes",
        "invalid": "fired with { errors } when it is submitted and a rule fails: errors maps a field name to its message"
      },
      "preview": {
        "slots": {
          "default": [
            {
              "component": "TextField",
              "slots": {
                "label": "Email",
                "hint": "We only use it to sign you in."
              }
            },
            {
              "component": "Button",
              "props": {
                "type": "submit"
              },
              "slots": {
                "default": "Sign up"
              }
            }
          ]
        }
      }
    }
    </contract>
    
    <script>
    // `values` is the form's data, by field name. `rules` maps a field name to a function that takes the field's value and all
    // the values, and returns a message when it is wrong, or an empty string (or nothing) when it is fine.
    const { values, rules, busy } = props({ values: {}, rules: {}, busy: false })
    
    const formEl = state(null)
    
    // The first field marked invalid gets focus, once the page has shown the errors.
    function focusFirstInvalid() {
      const root = formEl.value
      if (!root) return
      const first = root.querySelector('[aria-invalid="true"]')
      if (!first) return
      const target = first.matches('input, textarea, select, button')
        ? first
        : first.querySelector('input, textarea, select, button')
      if (target) target.focus()
    }
    
    function submit(event) {
      event.preventDefault()
      const data = values.value ?? {}
      const errors = {}
      for (const [name, rule] of Object.entries(rules.value ?? {})) {
        const message = rule(data[name], data)
        if (message) errors[name] = message
      }
      if (Object.keys(errors).length) {
        emit('invalid', { errors })
        setTimeout(focusFirstInvalid, 0)
      } else {
        emit('submit', { values: data })
      }
    }
    </script>
    
    <template>
      <form class="root" role="form" novalidate :aria-busy="busy ? 'true' : null" ref="formEl" @submit="submit($event)">
        <slot />
      </form>
    </template>
    
    <style>
    .root {
      display: flex;
      flex-direction: column;
      gap: $space.4;
      font-family: var(--t-font-family-sans);
      color: $color.text.default;
    }
    </style>
    
  2. Check your design tokens

    Your design system needs these. tessera add reports the ones that are missing; by hand, run npx @pixelscriptio/tessera check.

    color.text.default space.4

Usage

Using Form

A form that checks what was entered before it lets it through.

You give it the form's values and its rules. When the person submits (a button of type submit, or Enter in a field), it runs each rule. If every rule passes, it fires submit with the values. If one fails, it fires invalid with the errors, and moves focus to the first field marked invalid, so a keyboard or screen reader user lands on the first thing to fix.

  • A rule is a function from a field's value (and all the values) to a message. Return an empty string, or nothing, when the value is fine.
  • Form does not draw errors. You keep them in state and hand each to its field's error prop (TextField, Textarea, RadioGroup, FormField). The invalid event gives you all of them at once.
  • Browser validation is off (novalidate), so the messages are yours and say what you want in the words you want.
  • Say what is wrong and how to fix it: "Enter an email such as ada@example.com", not "Invalid". Show an error only after the person has tried to submit or left the field, not while they are still typing.
  • busy marks the form as busy for assistive technology while it is being sent. Disable the submit button yourself.

The preview needs JavaScript. The code is in the Code tab.

Form-0.tess
<script>
const values = state({ email: '', plan: '' })
const errors = state({})
const rules = {
  email: (v) => (/^\S+@\S+\.\S+$/.test(v) ? '' : 'Enter an email such as ada@example.com.'),
  plan: (v) => (v ? '' : 'Choose a plan.'),
}
const plans = [
  { value: 'free', label: 'Free' },
  { value: 'pro', label: 'Pro' },
]

function typed(value) {
  values.value = { ...values.value, email: value }
}
function chose(detail) {
  values.value = { ...values.value, plan: detail.value }
}
function invalid(detail) {
  errors.value = detail.errors
}
function sent() {
  errors.value = {}
}
</script>

<template>
  <Form :values="values" :rules="rules" @invalid="invalid($event)" @submit="sent()">
    <TextField :error="errors.email" @input="typed($event)">
      <span slot="label">Email</span>
    </TextField>
    <RadioGroup :options="plans" :error="errors.plan" @choose="chose($event)">
      <span slot="label">Plan</span>
    </RadioGroup>
    <Button type="submit">Sign up</Button>
  </Form>
</template>

API reference

Slots

SlotContent
defaultthe fields and the buttons

Events

EventWhen
submitfired with { values } when the form is submitted and every rule passes
invalidfired with { errors } when it is submitted and a rule fails: errors maps a field name to its message

Anatomy

Roleform
Partsroot
Since0.8.0

Design tokens it uses

Your design system needs these. tessera add checks that it has them, and can add the ones it lacks.

color.text.default space.4

Checksum

4dc86bb89d48253bd912cebd7cde9199f5982b6019bf12d62a5422db9f30cc46