Type-safe use
Classes and data attributes make the components framework-independent, but everything is strings:
<button class="lt-button" data-variant="secundary">Save draft</button>The button gets the primary style. No error message, just a variant that silently never took effect. That is why lt exists. If you write HTML without TypeScript, the extension for VS Code catches the same mistake.
The lt namespace
Section titled “The lt namespace”import { lt } from "@lo-tek/interfaces"
<button {...lt.button({ variant: "secondary" })}>Save draft</button><input {...lt.input({ type: "email", state: "invalid" })} /><span {...lt.badge({ color: "success" })}>Approved</span><a {...lt.link()} href="/receipt">View receipt</a>Type lt. in the editor, and you see the whole system. Each component is a function that takes an object of options and returns the attributes you spread into the element. The shape is the same for all of them.
The return values are plain objects. That is why the same API works in React, Astro, Datastar and plain HTML.
In plain DOM
Section titled “In plain DOM”In JSX you spread the object straight into the element. Without JSX, lt.setAttributes does the job:
const field = document.querySelector<HTMLInputElement>("#email")
lt.setAttributes(field, lt.input({ type: "email", state: "invalid" }))Call it again to change state. What the builder function manages and is not in the new set is removed. Otherwise the field would stay red after the error was fixed.
A builder function manages the attributes it has an option for, and only those:
| Call | On the element already | Afterwards |
|---|---|---|
lt.button({ variant }) | disabled, type="submit" | Both stay. The button function has no option for them |
lt.switch() | disabled | Removed. The switch has the option disabled, and you did not pass it |
lt.spinner() | role, aria-label | Removed. The spinner has the option label, which writes both |
So the rule is: if the builder function has an option for the attribute, you set it through the option, every time. If you write aria-label by hand on a spinner and call lt.spinner({ size: "large" }), the function removes it. id and for are never removed, and data-testid, name and your own classes are not touched.
If two sets go on the same element, such as the field’s appearance and the wiring from lt.field(), you pass them separately:
const wiring = lt.field({ id: "email", error: true, invalid: true })
lt.setAttributes(field, lt.input({ type: "email" }), wiring.control)Do not spread them together into one object. { ...lt.input(), ...wiring.control } is a new object, and then the function does not know which builder functions it came from. The attributes are set, but only data-* is cleaned up on the next call. The function warns in the console the first time it gets such a set with an lt- class in it.
Default values
Section titled “Default values”lt.button()// { class: "lt-button" }
lt.button({ variant: "primary" })// { class: "lt-button" }, primary is the default in the CSS
lt.button({ variant: "danger" })// { class: "lt-button", "data-variant": "danger" }You always say which variant you want; the function knows when the attribute can be left out.
Allowed values and guards
Section titled “Allowed values and guards”lt.button.variants// readonly ["primary", "secondary", "ghost", "danger"]
lt.button.isVariant("ghost") // truelt.button.isVariant("gost") // falseThe same pattern everywhere: lt.badge.colors and lt.badge.isColor, lt.input.types and lt.input.isType.
If the value comes from an API, a URL or localStorage, it is a string TypeScript cannot promise anything about. The guard gives a valid value or a safe fallback:
const response = await fetchButtonSettings()const variant = lt.button.isVariant(response.variant) ? response.variant : "primary"
lt.button({ variant })After the guard, TypeScript knows the value is a ButtonVariant.
One term, one meaning
Section titled “One term, one meaning”| Option | What it means | Where |
|---|---|---|
variant | Visual weight or meaning | button, card, tag, helpText, errorText and more |
color | What a status means | badge, alert, dialog, progress |
state | Validation state | input, textarea, select, search, checkbox, radio, fileUpload, fieldset |
type | The HTML type of the field | input |
state is one type across the field components, so a value can be passed between them without conversion:
const state = lt.isState(fromServer) ? fromServer : "default"
lt.input({ state })lt.textarea({ state })lt.select({ state })The web components
Section titled “The web components”TypeScript does not know <lt-field>, <lt-session-timeout> and <lt-connection-status> out of the box. Import the types once in a .d.ts file:
import "@lo-tek/interfaces/react-jsx"Then the attributes get autocompletion and checking:
<lt-field described-by="terms" /> // ok<lt-field describedBy="terms" /> // error: the attribute is called described-by<lt-toast closeLabel="Close" /> // error: the attribute is called close-label<lt-connection-status offline="Down" /> // error: unknown attributePlain HTML, Astro and Datastar do not need this. There, <lt-field> is just an HTML tag.
The events
Section titled “The events”The events the web components send, such as dialog-toggle and tab-select, have a typed detail in addEventListener. The types come with every entry point to the web components, including @lo-tek/interfaces/register, /react and /react-jsx, so what you already import is enough. The same goes for the tag names: document.querySelector("lt-dialog") gives LtDialog | null, not Element | null. The event types and the tag map extend the DOM types HTMLElementEventMap, DocumentEventMap and HTMLElementTagNameMap, and have nothing to do with React: the same applies in a TypeScript script in Astro or in a Datastar app, and in React through a ref. Since the events bubble, a listener on document is typed too.
import { defineLtDialog } from "@lo-tek/interfaces/dialog"
defineLtDialog()
document.querySelector("lt-dialog")?.addEventListener("dialog-toggle", (event) => { event.detail.open // boolean event.detail.returnValue // string, the value of the button that closed it})Every event and the fields in detail are also available as LtEventMap in the main entry point. A prop such as onDialogToggle or ondialog-toggle gets no type. React 18 wires neither of them, and React 19 only the latter, so in React 18 the listener would never have been called, without anything saying so.
What the types do not catch
Section titled “What the types do not catch”The attributes can be spread on the wrong element. <div {...lt.button({ variant: "danger" })} /> compiles, because the return value is just an object of HTML attributes.
The return type of type is wider than the value you passed in. lt.input({ type: "date" }).type is the whole union, not "date".
Without TypeScript
Section titled “Without TypeScript”lt is optional. Without TypeScript or a build step, this is just as valid:
<button class="lt-button" data-variant="secondary">Save draft</button>The result is identical. The difference is how early you find out that you wrote secundary.
If you only want one component:
import { button } from "@lo-tek/interfaces/button"The subpaths return class and for, like the main entry point. The React variant exists only as the combined lt.