Extensions#
Opt-in capabilities, imported separately, tree-shaken away when unused. Form participation, attribute-driven reactivity, and debug instrumentation extend defineComponent() through a third argument.
How Extensions Work#
The core of Le Truc stays small by staying out of the way. When a component needs more than the core — a seat in a native <form>, say — that capability ships as an extension: a small, tree-shakable module passed as the third argument to defineComponent().
defineComponent('my-element', factory, [formAssociated()])
Each extension implements the ComponentExtension interface:
- A
name - A set of
staticPropsto install on the generated class (e.g.static formAssociated = true) observedAttributesandreservedMembersit contributes- Optional lifecycle hooks (
installOnPrototype,onConnect,onAttributeChanged)
defineComponent() folds the array once at class-definition time. staticProps collisions throw ExtensionCollisionError in dev mode (first declaration wins in production). observedAttributes and reservedMembers are unions across all extensions.
Le Truc ships three extensions, each imported separately:
| Extension | Purpose |
|---|---|
formAssociated() | Form participation via ElementInternals — value sync, reset, state restore, disabled, native-parity host contract |
formAssociatedCheckbox() | Form participation keyed on a checked: boolean prop — submits nothing when unchecked |
observedAttributes() | Re-parses Parser-backed props when their attribute mutates after connect |
A fourth extension, debug(), ships too — but it doesn't appear in this table, because you never add it yourself.
Form Association#
The formAssociated() extension adapts a component to the form-associated custom element convention. Pass it as the first element of the extensions array:
defineComponent<FormTextboxProps>(
'form-textbox',
({ expose, first, host, internals, watch }) => {
const textbox = first('input, textarea')
expose({ value: textbox.value })
// Typed validity flags via the internals escape hatch
watch(
() => ({ value: host.value, max: host.maxLength }),
({ value, max }) => {
internals?.setValidity(
{ tooLong: value.length > max },
value.length > max ? `Max ${max} characters` : '',
)
},
)
},
[formAssociated()],
)
With [formAssociated()], Le Truc manages for you:
- Form value sync
- Reset
- State restore
- A
<fieldset disabled>-awaredisabledproperty
The host gains a native-parity contract delegating to internals: form, name, labels, validity, validationMessage, willValidate, checkValidity(), reportValidity(), setCustomValidity(). It also gains a managed defaultValue property — the reset baseline, mirroring <input>.defaultValue. When the prop is Parser-backed, defaultValue reflects the live value content attribute through that Parser. Setting it moves the baseline for the next form reset; it never changes the live value. External consumers read them as on a native input. The convention requires a reactive value property. Expose it and sync it to the underlying native control as usual. expose() throws InvalidPropertyNameError for any reserved member name managed by the extension — defaultValue is one of them.
The internals object on the context (null only if attachInternals() failed) is the escape hatch for typed validity flags, custom :state() pseudo-classes, and ARIA reflection. Follow this rule: use internals?.setFormValue() indirectly through the managed convention. Set value, and it syncs automatically. Call internals?.setValidity() directly when you need flags beyond a simple custom-error message.
Checkbox-Shaped Controls#
A checkbox's primary state is checked: boolean. It submits nothing when unchecked, unlike formAssociated()'s always-on string value. The formAssociatedCheckbox() extension handles this shape. It shares the same host contract and disabled management as formAssociated(). Its value-sync, reset, and state-restore mechanics target a checked prop instead of value:
defineComponent<FormCheckboxProps>(
'form-checkbox',
({ expose, first, on, watch }) => {
const checkbox = first('input[type="checkbox"]')
expose({ checked: asBoolean() })
on(checkbox, 'change', () => ({ checked: checkbox.checked }))
watch('checked', bindProperty(checkbox, 'checked'))
},
[formAssociatedCheckbox()],
)
internals.setFormValue() receives the host's own value attribute when checked (default 'on', matching native <input type="checkbox">) and null when unchecked. The convention requires a reactive checked property. The reset baseline is a managed defaultChecked property, mirroring <input>.defaultChecked: it reflects the checked attribute, and setting it moves the baseline for the next form reset without changing the live checked.
Do not combine the two form extensions
Both formAssociated() and formAssociatedCheckbox() declare the same staticProps.formAssociated key. Combining them on one component throws ExtensionCollisionError in dev mode. Radio groups and listboxes do not need formAssociatedCheckbox(). Their selection aggregates into one string value on the container, which fits formAssociated().
Relaying Native Control Validity#
A component that wraps a native control (<input>, <select>, <textarea>) — a spinbutton around <input type="number">, a masked field around <input type="text"> — can relay the control's own ValidityState onto host.validity with relayValidity(internals, control, anchor?). This surfaces every constraint the browser already checks (rangeOverflow, stepMismatch, badInput, valueMissing, …), instead of collapsing them into a single customError. It fully replaces host.validity, including the control's own customError — the control's live state is the whole truth about itself. It is not reactive. Call it from an event handler on the wrapped control:
import { defineComponent, formAssociated, relayValidity } from '@zeix/le-truc'
export default defineComponent(
'form-enhanced-input',
({ first, internals, on }) => {
const input = first('input', 'Add a native input')
on(input, 'input', () => relayValidity(internals, input))
},
[formAssociated()],
)
relayValidity() isn't gated behind formAssociated() — it works with any component that has internals on its factory context. See form-spinbutton.ts for a complete example.
Attribute-Driven Reactivity#
Properties are the primary reactive interface. By design, a Parser passed to expose() reads its attribute once, at connect time. Attribute changes after connect do not re-run it. The observedAttributes() extension is the opt-in escape hatch. Use it when you need the parser to fire again on later attribute mutations. This matters for interoperability with frameworks like React that set DOM attributes on custom elements rather than properties:
defineComponent<BasicGaugeProps>(
'basic-gauge',
({ expose, watch }) => {
expose({ value: asNumber() })
watch('value', v => { /* update the gauge */ })
},
[observedAttributes(['value'])],
)
Le Truc adds named attributes to the class's static observedAttributes. On each mutation, the extension re-runs the same retained Parser against the attribute's new string value. It writes the result to the prop. Props whose initializer is not a branded Parser are left untouched.
Use this sparingly. For most components, event handlers or direct property writes are the right way to update state after connect.
Do not observe value or checked on a form-associated component
On a form-associated component, the value attribute is the reset baseline, not a live-value channel. Passing value to observedAttributes() re-parses the baseline attribute into the live prop on every mutation, so baseline updates apply live and the two channels stop being distinct. The same applies to checked with formAssociatedCheckbox(). Use the property as the sole live edit path.
Debug Instrumentation#
debug() is not exported, and you never pass it to defineComponent(). Build your app with DEV_MODE="true", and every component gets a reactive debug: boolean property for free. Instrumenting one specific component instance shouldn't require editing its source.
Toggle debug from the browser's properties panel, or hold Cmd/Ctrl and click the component. While debug is true:
- The host carries a pulsing box-shadow indicator on every
on(),pass(), orwatch()firing - Target elements that
on(),pass(), or abind*-backedwatch()handler act on get a presence-only marking attribute (data-le-truc-on,-pass, or-watch) - Each firing logs one
console.debug()entry naming the component and, where known, the event or target
A watch() handler not produced by a bind* helper (bindText, bindProperty, and so on) can't be traced back to an element. Le Truc shows the host-level pulse only, rather than guess.
debug does nothing in production. The property doesn't exist without DEV_MODE, because the extension that provides it was never added to the component.
debug is a reserved property name in DEV_MODE
expose({ debug: ... }) throws in a DEV_MODE build, on any component. The name is reserved the moment DEV_MODE is on. Avoid debug as a prop name.