# Field

> Frame component that wires the label, field, help text and error text together for you.

A form field is accessible when the help text and the error text are wired to the field itself. LO-TEK gives you that wiring in two ways, and **you use one of them, not both**.

| Do you produce the markup with JavaScript? | Use |
| - | - |
| **Yes.** React, Astro, a Node server, or an app with no server at all | [`lt.field()`](#as-a-function). Then you do not need the web component |
| **No.** A Go template, a PHP file, a Razor view or handwritten HTML | [`<lt-field>`](#as-an-element) |

Both use the same function underneath, so the contract cannot drift apart. The only difference is whether the code that produces the HTML can call a TypeScript function.

`<lt-field>` creates no markup itself and does not use Shadow DOM, so `FormData`, form libraries and test tools find the elements as before.

> **Other components are not either-or**
>
> `<lt-field>` can be replaced entirely by the builder function, because the wiring is markup and not behaviour. The same goes for [Dialog](/components/dialog.md), when the app calls `showModal()` itself. [Tabs](/components/tabs.md), [Popover](/components/popover.md), [Suggestion](/components/suggestion.md) and [Error Summary](/components/error-summary.md) do something a function cannot, like arrow keys and focus, so there you use the builder function **and** the component. See [Markup and behaviour](/markup-and-behaviour.md#builder-function-or-web-component).

## How to use it

`defineLtField()` registers the web component in the browser, and is called once at startup. More calls are harmless. The stylesheet must be imported because the children are elements you write, with the classes `lt-label`, `lt-input` and so on, and those classes must be on the page. `field.css` collects them.

**Plain HTML**

```html
<!-- The stylesheets the page needs. Without a build step it is `<link>`,
     not `import`: a browser does not resolve a package name. -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/tokens/tokens.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/frame/field/field.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/css/button/button.css">
<!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/lotek.css -->
```

```html
<!-- novalidate: otherwise the browser stops an empty required field before
     the submit event, and the error text below is never shown. -->
<form id="contact-form" novalidate>
  <lt-field>
    <label class="lt-label" for="name" data-required="symbol">Full name</label>
    <input class="lt-input" id="name" type="text" name="name" required />
    <p class="lt-error-text" hidden>Enter your name.</p>
  </lt-field>


  <button class="lt-button" type="submit">Send</button>
</form>


<!-- Once for the whole app, in the page template. Not once per component. -->
<script type="module">
  import { defineLtField } from
    "https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/components/frame/field/lt-field.js"


  defineLtField()
</script>


<script type="module">
  const form = document.getElementById("contact-form")
  const nameInput = document.getElementById("name")
  const error = nameInput.closest("lt-field").querySelector(".lt-error-text")


  form.addEventListener("submit", (event) => {
    event.preventDefault()


    const empty = nameInput.value.trim() === ""
    error.hidden = !empty


    if (empty) {
      nameInput.focus()
      return
    }


    const data = new FormData(form)
    console.log(Object.fromEntries(data))
  })
</script>
```

You write `for` and `id` yourself, as HTML requires for the field to have a name before the script has run. The field is invalid when the error text is visible: the script toggles `hidden`, and the component wires the error text to the field and sets `aria-invalid`. `new FormData(form)` picks up the field by itself. That is the whole point of the component staying in plain DOM.

**React**

In React you call `lt.field()`. Then the wiring is in the markup React renders itself, on the server as in the browser, and you do not need to register anything.

```tsx
// main.tsx, runs once when the app starts
import "@lo-tek/interfaces/tokens.css"
import "@lo-tek/interfaces/field.css"
import "@lo-tek/interfaces/button.css"
```

```tsx title="ContactForm.tsx"
import { lt } from "@lo-tek/interfaces/react"
import { useId, useState } from "react"


export function ContactForm() {
  const [name, setName] = useState("")
  const [touched, setTouched] = useState(false)


  const invalid = touched && name.trim() === ""
  const field = lt.field({
    id: useId(),
    required: "symbol",
    error: true,
    invalid,
  })


  return (
    <form
      noValidate
      onSubmit={(e) => {
        e.preventDefault()
        setTouched(true)
      }}
    >
      <label {...field.label}>Full name</label>
      <input
        {...lt.input({ type: "text" })}
        {...field.control}
        name="name"
        required
        value={name}
        onChange={(e) => setName(e.target.value)}
        onBlur={() => setTouched(true)}
      />
      <p {...lt.errorText()} {...field.error}>Enter your name.</p>


      <button {...lt.button()} type="submit">Send</button>
    </form>
  )
}
```

`required: "symbol"` gives the asterisk on the label. That the field must be filled in is said by `required` on the field itself.

> **In React the id comes from useId()**
>
> An id that is generated on its own is not the same on the server and in the browser, and React then reports a hydration mismatch on the label, the field and the error text. The warning itself says the mismatch will not be patched up. `createFieldId()` will not do in a component body either: it gives a new id on every render.

> **The web component gives hydration errors in React with server rendering**
>
> The component writes `aria-describedby`, ids and the class on the elements when it is registered, before React hydrates. React then finds attributes it did not render, and the React 19 development build reports “A tree hydrated but some attributes of the server rendered HTML didn’t match the client properties”. The field works, but the error is in the console on every page with a field. Use `lt.field()`.
>
> If the app renders only in the browser, without a server, `<lt-field>` also works in React. Invalid is then controlled with `hidden={!invalid}` on the error text, a plain HTML attribute React writes the same way in all versions.

**Astro**

If the field only needs to be shown in one state, `lt.field()` does the wiring at build time. Then no JavaScript is sent for the field at all:

```astro
---
import { lt } from "@lo-tek/interfaces"
import "@lo-tek/interfaces/tokens.css"
import "@lo-tek/interfaces/field.css"


const name = lt.field({ id: "name", required: "symbol", error: true })
---


<label {...name.label}>Full name</label>
<input {...lt.input({ type: "text" })} {...name.control} name="name" required />
<p {...lt.errorText()} {...name.error}>Enter your name.</p>
```

If the field should be able to become invalid while the user is in it, use `<lt-field>` and register it in a `<script>` tag. Astro bundles it and runs it on the client:

```astro
---
import { lt } from "@lo-tek/interfaces"


import "@lo-tek/interfaces/tokens.css"
import "@lo-tek/interfaces/field.css"


const name = lt.field({ id: "name", required: "symbol", error: true })
---


<lt-field>
  <label {...name.label}>Full name</label>
  <input {...lt.input({ type: "text" })} {...name.control} name="name" required />
  <p {...lt.errorText()} {...name.error}>Enter your name.</p>
</lt-field>


<script>
  import { defineLtField } from "@lo-tek/interfaces/field"
  defineLtField()
</script>
```

**Datastar**

[Datastar](https://data-star.dev) adds reactivity to plain HTML with `data-*` attributes. One script, no build step.

```html
<script
  type="module"
  src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar.js"
></script>
```

```html
<!-- The stylesheets the page needs. Without a build step it is `<link>`,
     not `import`: a browser does not resolve a package name. -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/tokens/tokens.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/frame/field/field.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/css/input/input.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/css/error-text/error-text.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/css/button/button.css">
<!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/lotek.css -->
```

```html
<form
  novalidate
  data-signals="{name: '', touched: false}"
  data-computed:invalid="$touched && $name.trim() === ''"
  data-on:submit__prevent="$touched = true; !$invalid && @post('/contact')"
>
  <lt-field>
    <label class="lt-label" for="name" data-required="symbol">Full name</label>
    <input
      class="lt-input"
      id="name"
      type="text"
      name="name"
      required
      data-bind:name
      data-on:blur="$touched = true"
    />
    <p class="lt-error-text" hidden data-attr:hidden="!$invalid">Enter your name.</p>
  </lt-field>


  <button class="lt-button" type="submit">Send</button>
</form>


<!-- Once for the whole app, in the page template. Not once per component. -->
<script type="module">
  import { defineLtField } from
    "https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/components/frame/field/lt-field.js"


  defineLtField()
</script>
```

`data-attr:hidden` hides the error text when the field is valid and shows it when it is invalid. `<lt-field>` sees that the error text is visible, and sets `aria-invalid` and `aria-describedby` on the field. The field has no id the component created, so a patch from the server recognises it, and what the user has typed stays.

**`lt.input()` should only get `type`.** The state is in `field.control`, and the two deliberately do not overlap. If you passed `state` to both, `aria-invalid` would be written out twice in templates that render the attributes literally, like Astro and plain HTML. `field.state` still exists if you want to drive something else with it.

### The required marker

`data-required` on the label chooses how a required field is marked, `symbol` or `text`, and is in the markup, so the marker is visible also before the script has run. `optional` on `<lt-field>` turns it around: mark the optional field instead of the required one, when almost everything in the form must be filled in. `optional` has no place in the recipe above, since it shows one field and not a form.

```html
<lt-field>
  <label class="lt-label" for="email" data-required="text">Email address</label>
  <input class="lt-input" id="email" type="email" required />
</lt-field>


<lt-field optional>
  <label for="message">Message to the case officer</label>
  <textarea class="lt-textarea" id="message" rows="3"></textarea>
</lt-field>
```

## Why the wiring is hard

A field with help text and an error text needs five connections that must all be right at once:

```html
<label for="email">Email address</label>
<input id="email" aria-invalid="true" aria-describedby="email-help email-error" />
<p id="email-help">We send the receipt here.</p>
<p id="email-error">Enter a valid email address.</p>
```

Every `id` must be unique, `aria-describedby` must point to both texts, and `aria-invalid` must follow the validation. Five places to get it wrong per field, multiplied by the number of fields. And nothing shows on the screen when one of them is missing.

## As a function

`lt.field()` computes the attributes where you render, whether at build time in Astro, on every render in React, or on the server. The browser needs no registered component, and the wiring is ready in the HTML before any script has run:

```tsx
const field = lt.field({ id: "email", required: "symbol", help: true, error: true, invalid })


<label {...field.label}>Email address</label>
<input {...lt.input({ type: "email" })} {...field.control} />
<p {...lt.helpText()} {...field.help}>We send the receipt here.</p>
<p {...lt.errorText()} {...field.error}>Enter a valid address.</p>
```

If you use React, import from `@lo-tek/interfaces/react`, which gives `className` and `htmlFor` instead of the HTML names.

**`id` is required.** In React it comes from `useId()`. Otherwise the field’s own name is usually the obvious choice. If you know for certain that the markup is rendered once, as on an Astro page, you can call `createFieldId()`, which you get from both `@lo-tek/interfaces` and `@lo-tek/interfaces/react`.

The reason is that an id generated on its own is random: two runs give two different ones. If the same field is rendered on a server and then in the browser, `for` and `aria-describedby` point to something other than what is there, and React reports a hydration mismatch.

If you write JavaScript without types, you see no type. Then the function creates an id anyway, and warns in the console, so the field works while you fix it.

## As an element

If the code that produces the HTML cannot call a TypeScript function, `<lt-field>` does the same wiring in the browser. You write your elements as usual, with `for` on the label and `id` on the field, or with the label around the field. That is what HTML requires for the field to have a name before the script has run, and for voice control to find it. The component sets `aria-describedby` and `aria-invalid` on the field and gives the help text and the error text ids.

The component never writes an id on the field. Datastar and idiomorph recognise an element by its id, and a field with an id the server did not send would be replaced on the next update, along with focus and what the user has typed. Without an id from the component the field stays.

If the server sends the same region again while the user is working, as in a Datastar app, the morph tears the wiring away: none of it was in the HTML the server sent. The component sees that and puts it back, so your template needs no `data-preserve-attr`. The reasoning is in [Markup and behaviour](/markup-and-behaviour.md#when-the-markup-is-not-made-with-javascript).

In a loop you make the id from a key in the data, like `note-{{.Code}}`, never from the index or a counter. An id that follows the position moves what the user typed to the wrong row when the server inserts a new one. If the template makes the id from something that changes between renders, the component warns in the console when an update replaced the field for that reason.

If the component cannot find its parts, it warns in the console. An `<lt-field>` without a control gives a warning naming what is missing, instead of a field that silently has no wiring. The same goes for a field without a label, but only when it has no name in any other way either: a `<label for>` outside the element, `aria-label` or `aria-labelledby` on the control counts, as in a search field with only an icon. And a field with more than one control gives a warning, since only the first gets the wiring.

## Automatic wiring

Every time the content or the attributes change, it finds the `<label>` and the first control among its own parts and:

1. Sets `label[for]` to the control’s `id` when the control has one, and the label is not around the control. The control never gets an id from the component.
2. Gives the help text and the error text an `id` when needed.
3. Sets `aria-describedby` on the control: the help text always, and the error text as well when it is visible. A hidden error text should not be announced.
4. Sets `aria-invalid="true"` on the control when the error text is visible, meaning it has text and is without `hidden` and without `display: none`. `data-show` in Datastar and `data-text` in a container that is empty therefore also work.
5. Sets `data-optional` on the label when `<lt-field>` has `optional`.

The error text can be in the markup all the time with `hidden`, or be rendered by the template only when the field is invalid. The component never touches `hidden` on it.

The component requires two class names from you: `lt-help-text` on the help text and `lt-error-text` on the error text. It finds the control by tag name, `<input>`, `<textarea>` or `<select>`, and a `type="hidden"` does not count as a control. It finds the label as a `<label>` inside the element, or as a `<label for>` outside that points to the control’s `id`. If the control has no `id`, and the label is not around it, the component warns: the field then has no name without JavaScript. If the control has an `id`, the component writes `for` from it, and warns in the console when the label pointed to a different id: without JavaScript the field would then have had no label. Write `for` in the template too; that is what gives the field a name before the script runs.

The parts are only those that belong to this field. If one `<lt-field>` is inside another, like a search field in a popover inside the field, the outer field sees only the control, label, help text and error text that belong to it, and the inner field likewise.

## Attributes

| Attribute | Type | What it does |
| - | - | - |
| `disabled` | boolean | Sets `disabled` on the control and dims the label |
| `optional` | boolean | Sets `data-optional` on the label |
| `described-by` | string | Adds extra `id`s to `aria-describedby`, in addition to help and error |

There is no `invalid` attribute. The field is invalid when the error text is visible, or when the server has written `aria-invalid` on the control. The required marker is `data-required` on the label, and is only visual: `required` must be on the `<input>` itself.

## Accessibility

- The component does the wiring, not the content. You still have to write the label and the error message well.
- Write `for` on the label and `id` on the field, or put the label around the field. Without that the field has no name before the script has run. Without an id the field does not get focus back after an `innerHTML` update with htmx, which finds it again by id, and voice control on Windows has not been checked with the label around the field.
- Set `required` on the `<input>` in addition to `data-required` on the label.
- Show the error message when the user has left the field, not on every keystroke. The message is announced every time it appears.
- One control per `<lt-field>`. The component wires itself to the first one it finds. Groups belong in a `<fieldset>` with `<legend>`.
