Skip to content

Field

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.

Can the code that renders the markup call a builder function?Use
Yes. React, Astro, a Node server, an app with no server at all, or a Kotlin or Java serverlt.field(), or Lt.field(). Then you do not need the web component
No. A Go template, a PHP file, a Razor view, a JTE or Thymeleaf template, a CMS or hand-written HTML<lt-field>

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 builder function, in TypeScript, Kotlin or Java.

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

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.

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.

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.

<lt-field>
<label class="lt-label" for="fixer" data-required="text">Fixer's address</label>
<input class="lt-input" id="fixer" type="email" name="fixer" required />
</lt-field>
<lt-field optional>
<label for="note">Note for the fixer</label>
<textarea class="lt-textarea" id="note" rows="3"></textarea>
</lt-field>

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

<label for="fixer">Fixer's address</label>
<input id="fixer" aria-invalid="true" aria-describedby="fixer-help fixer-error" />
<p id="fixer-help">The board writes to the fixer, never to the crew.</p>
<p id="fixer-error">Enter the fixer's address, for example fixer@relay.example.</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.

lt.field() computes the attributes where you render, whether at build time in Astro, on every render in React, or on the server. On a Kotlin or Java server it is Lt.field(), with the same options, as the kotlinx.html tab above shows. The browser needs no registered component, and the wiring is ready in the HTML before any script has run:

const field = lt.field({ id: "fixer", required: "symbol", help: true, error: true, invalid })
<label {...field.label}>Fixer's address</label>
<input {...lt.input({ type: "email" })} {...field.control} />
<p {...lt.helpText()} {...field.help}>The board writes to the fixer, never to the crew.</p>
<p {...lt.errorText()} {...field.error}>Enter the fixer's address, for example fixer@relay.example.</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.

If the code that produces the HTML cannot call a builder 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.

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.

Send the field and its error text in the same patch. If the server clears the error by sending only the <input>, the old error text stays visible, and the component reads that as invalid and writes aria-invalid back.

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.

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.

AttributeTypeWhat it does
disabledbooleanSets disabled on the control and dims the label
optionalbooleanSets data-optional on the label
described-bystringAdds extra ids 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.

What a screen reader is given when the recipe above is used, step by step. It is checked through AT-SPI, the interface Orca reads, in Chromium and Firefox, on every change, by check-accessibility-tree.ts, and every change the check lists as a mutation must make it fail.

  1. Focus #codename: focus on the textbox "Codename *", required, not invalid.
  2. Press Enter: focus on the textbox "Codename *", described as "Enter a codename.", required, invalid; the change to invalid sent as an event; the new description sent as an event.

Where a browser does not do what the specification says, which the check requires until the browser changes:

  • Firefox: Firefox 150 (Playwright firefox-1522), 2026-10-29: an empty required input has STATE_INVALID_ENTRY before anyone has touched it, also in a form with novalidate. HTML-AAM, the required attribute: "If an element is required, user agents MUST NOT expose the element with an initial invalid state". A screen reader user in Firefox hears "invalid entry" on a field they have not filled in yet.
  • Firefox: Firefox 150, 2026-10-29: follows from the deviation at field:focus-field. The field was already invalid-entry before the submit, so there is no change to send.
  • Firefox: Firefox 150, 2026-10-29: when the component wires the error text with aria-describedby, Firefox has the new description when asked, but sends no description change event, which Core-AAM requires for aria-describedby. With the focus staying in the field, Orca says nothing about the error text until the focus moves.
  • 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>.