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.

Do you produce the markup with JavaScript?Use
Yes. React, Astro, a Node server, or an app with no server at alllt.field(). Then you do not need the web component
No. A Go template, a PHP file, a Razor view or handwritten 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 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.

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="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>

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

<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.

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:

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.

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.

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.

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.

  • 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>.