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 all | lt.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.
How to use it
Section titled “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.
We send the receipt to this address.
Enter an email address with an @ sign.
You can write up to 500 characters.
<!-- 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 --><!-- 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.
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.
// main.tsx, runs once when the app startsimport "@lo-tek/interfaces/tokens.css"import "@lo-tek/interfaces/field.css"import "@lo-tek/interfaces/button.css"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.
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:
---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:
---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 adds reactivity to plain HTML with data-* attributes. One script, no build step.
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar.js"></script><!-- 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 --><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
Section titled “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.
<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
Section titled “Why the wiring is hard”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.
As a function
Section titled “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:
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
Section titled “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.
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
Section titled “Automatic wiring”Every time the content or the attributes change, it finds the <label> and the first control among its own parts and:
- Sets
label[for]to the control’sidwhen the control has one, and the label is not around the control. The control never gets an id from the component. - Gives the help text and the error text an
idwhen needed. - Sets
aria-describedbyon the control: the help text always, and the error text as well when it is visible. A hidden error text should not be announced. - Sets
aria-invalid="true"on the control when the error text is visible, meaning it has text and is withouthiddenand withoutdisplay: none.data-showin Datastar anddata-textin a container that is empty therefore also work. - Sets
data-optionalon the label when<lt-field>hasoptional.
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
Section titled “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 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.
Accessibility
Section titled “Accessibility”- The component does the wiring, not the content. You still have to write the label and the error message well.
- Write
foron the label andidon 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 aninnerHTMLupdate with htmx, which finds it again by id, and voice control on Windows has not been checked with the label around the field. - Set
requiredon the<input>in addition todata-requiredon 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>.