Overview & Philosophy
ln-validate is applied to form controls (<input data-ln-validate>, <select>, <textarea>) inside a .form-element wrapper. On initialization, it automatically sets novalidate on the host form to take control of validation messaging. On field blur or input (after touched), it evaluates the browser's native C++ ValidityState API, dynamically unhiding matching error messages ([data-ln-validate-error="rule"]) and updating aria-invalid. If validation errors exist on form submit, it cancels submission and automatically focuses the first invalid input.
In ln-ashlar, validation is handled as a pure validity primitive (~160 lines of JavaScript) that wraps native browser APIs instead of relying on heavy client-side schema engines.
The Primitive Doctrine
A validity primitive has no awareness of form-level submissions or target button states. It focuses on a single field, managing its binary touched/untouched state, native constraints, and custom server-side errors. It publishes its status by toggling classes and dispatching bubbled events. The DOM attributes are the single source of truth.
The Mindset: Three Isolated Concerns
Each validated field on this page is a perfect synchronization of three isolated concerns, layered gracefully on top of each other:
-
The Browser Engine (Native Constraints): Constraints are declared directly in native HTML attributes (like
required,type="email",minlength="5", orpattern="..."). The browser's native C++ engine handles the complex parsing and validation matching instantly. -
The Visual Interface (CSS Classes & Errors): Visual state is bound to standard CSS classes:
.ln-validate-validfor success and.ln-validate-invalidfor failures, while error messages start hidden (.hidden) in a sibling<ul data-ln-validate-errors>. -
The Lifecycle Manager (
ln-validate): The JavaScript component acts purely as the lifecycle coordinator. It gates visual feedback so fields remain silent until they are touched, maps activeValidityStatefailures to their corresponding HTML error items, and exposes programmatic methods (likevalidate()andreset()).
How the Interaction Flows
When a user starts typing in a validated input:
- The component intercepts the
inputorchangeevent and instantly flips the field's internal state to_touched = true. - The component queries
dom.checkValidity(). If it fails, it adds.ln-validate-invalidto the input, matches the browser's activeValidityStatekey (e.g.valueMissing) to the target HTML element (data-ln-validate-error="required"), removes its.hiddenclass, and dispatches a bubbledln-validate:invalidCustomEvent. - If validation passes, it cleans up all visual error classes, restores the
.hiddenstate on all message elements, and dispatches a bubbledln-validate:validCustomEvent.
For a deeper dive into the architectural rationale, see the Data Flow Architecture, the ln-validate README, and the architecture deep-dive.
Anatomy
The minimum invocation is data-ln-validate on a field plus a sibling <ul data-ln-validate-errors> error list inside a .form-element wrapper.
<div class="form-element">
<label for="email">Email</label>
<input id="email" name="email" type="email" required minlength="5" data-ln-validate>
<ul data-ln-validate-errors>
<li class="hidden" data-ln-validate-error="required">Email is required</li>
<li class="hidden" data-ln-validate-error="typeMismatch">Invalid email format</li>
<li class="hidden" data-ln-validate-error="tooShort">Must be at least 5 characters</li>
</ul>
</div>
The field — [data-ln-validate]
Marker on <input>, <select>, or <textarea>. Presence creates the instance; the value is ignored. Validation rules come from native HTML attributes — data-ln-validate is a participation marker, not a rule definition.
The error list — [data-ln-validate-errors]
A sibling <ul> inside the same .form-element wrapper. The component finds it via dom.closest('.form-element').querySelector('[data-ln-validate-errors]'), so the wrapper class must be .form-element — that string is hardcoded.
The error message — [data-ln-validate-error="key"]
Each <li> carries a key matching either a ValidityState property (mapped) or a custom-error name (unmapped). Both flavours live in the same <ul> — the component only renders native errors via the validation loop and only renders custom errors via the set-custom / clear-custom event handlers. The split is invisible in the markup; it only matters in the code path.
What state goes where
The instance state is two fields: _touched (Boolean — has the user interacted yet?) and _customErrors (Set of active custom-error names). The DOM holds the value; the browser holds validity. The CSS classes (ln-validate-valid, ln-validate-invalid) on the field are the visible state for project SCSS to hook. The .hidden class on each <li> is the per-message visibility toggle.
The _touched gate — silent until first interaction
The single most consequential UX decision in the component. Validation runs silently until the field has fired its first input or change event. Without this, every page load would render a wall of "this field is required" messages on every empty form. With it, errors appear only after the user has actually engaged with the field.
The flag is forward-only during normal flow — once true, stays true until reset(). Note that validate() itself does NOT check _touched; force-validating an untouched field renders errors immediately. That is intentional — on form submit (for scoped mutation forms), ln-form dispatches the ln-validate:request-validate event on the form. Each field component catches it, runs validate() (which updates visual error states regardless of touched status), and reports back if it is invalid.
The submit button remains enabled at all times, preventing the confusing "disabled-until-valid" anti-pattern. If validation fails, submission is blocked, inline errors are shown, and focus shifts to the first invalid field.
The listener discriminator — input vs change
The constructor reads the field's tag and type to decide which events to listen for:
const isChangeBased = tag === 'SELECT' || type === 'checkbox' || type === 'radio';
isChangeBased | Field types | Listeners |
|---|---|---|
false | text, email, tel, url, password, number, search, date, time inputs; textarea | input AND change |
true | <select>, <input type="checkbox">, <input type="radio"> | change only |
The fork exists because input does not fire on SELECT, checkbox, or radio elements — the platform's contract for those is "value commits on change." This same discriminator is mirrored exactly in ln-form.fill(), ln-form.reset(), and ln-autosave's synthetic-event dispatch — programmatic value writes route through the same listener as user interaction.
Error key mapping
data-ln-validate-error | HTML constraint | ValidityState property |
|---|---|---|
required | required | valueMissing |
typeMismatch | type="email", type="url", type="number", etc. | typeMismatch |
tooShort | minlength | tooShort |
tooLong | maxlength | tooLong |
patternMismatch | pattern | patternMismatch |
rangeUnderflow | min | rangeUnderflow |
rangeOverflow | max | rangeOverflow |
Keys not in this map are treated as custom errors — managed exclusively via ln-validate:set-custom / ln-validate:clear-custom. Validation rules come from native HTML attributes, with zero JS configuration; error message text lives in HTML, rendered by the backend (multilanguage ready).
Demo — Text input (required + minlength)
- Name is required
- Must be at least 3 characters
Demo — Email validation
- Email is required
- Invalid email format
- Invalid email format (missing top-level domain)
- Must be at least 5 characters
Demo — Select field (change-based)
- Please select a department
Demo — Pattern validation
- Invalid phone format (e.g. +389 70 123456)
Demo — Number range
- Minimum salary is 20,000
- Maximum salary is 200,000
Demo — Custom validation (password confirmation)
Custom errors that native HTML cannot express. The coordinator compares values cross-field and dispatches ln-validate:set-custom when they mismatch, ln-validate:clear-custom when they match. The passwordMismatch key is NOT in ERROR_MAP — its <li> is invisible to the native validation loop and only toggles via the event handlers.
Programmatic API
Force-validate every field above, reset all of them, or check current validity. Each button below operates on every [data-ln-validate] on this page.
const input = document.getElementById('email');
input.lnValidate.validate(); // Force-validate now — returns Boolean
input.lnValidate.reset(); // Clear _touched, hide all errors, remove classes
input.lnValidate.isValid; // Live getter — checkValidity() && _customErrors.size === 0
input.lnValidate.destroy(); // Detach listeners
Attributes reference
| Attribute | On element | Description |
|---|---|---|
data-ln-validate | <input>, <select>, <textarea> | Marks the field as participating. Creates the instance, attaches listeners. Value ignored. |
data-ln-validate-errors | <ul> | Identifies the error-message list. Must be inside the same .form-element wrapper as the field. |
data-ln-validate-error="key" | <li> | Identifies a specific error message. Key matches a ValidityState property (native, mapped) or a custom-error name (unmapped). |
.hidden (class) | <li> | Toggle target. Default-applied; removed by validate() (or set-custom) when the matching error is active. |
.ln-validate-valid (class) | field (auto) | Written by ln-validate when isValid is true. |
.ln-validate-invalid (class) | field (auto) | Written by ln-validate when isValid is false. |
Events — emitted
| Event | Cancelable | detail | Dispatched when |
|---|---|---|---|
ln-validate:valid | no | { target, field } | After validate() determines the field is valid (native + no custom errors). |
ln-validate:invalid | no | { target, field } | After validate() determines the field is invalid. |
All events bubble. detail.field is dom.name — the value of the name attribute, exposed for consumers that want to key per-field state without re-querying the DOM.
Events — received
| Event | detail | Behavior |
|---|---|---|
ln-validate:set-custom | { error: String } | Adds error to _customErrors, sets _touched = true, unhides the matching <li>, flips CSS classes to invalid. Does NOT call validate() or dispatch :invalid. |
ln-validate:clear-custom | { error: String } or {} | If error provided: removes from set, hides matching <li>. If empty: clears all custom errors, hides every custom <li>. Then if _touched, calls validate() to re-render native state. |
The asymmetry is intentional. set-custom is silent because the caller already knows the field is invalid. clear-custom must call validate() because removing one custom error might leave other customs active, native errors active, or none of the above — only a fresh validate() can resolve the new state.
Event log
Validation events from every demo on this page appear here in real time. Try interacting with the fields above and watch the events fire — note especially how set-custom on the password-confirm field does NOT dispatch :invalid (asymmetric custom-error path).
Cross-component coordination
| Component | Coordination |
|---|---|
ln-form | On submit (for scoped mutation paths), dispatches the synchronous ln-validate:request-validate event on the form. Field validators catch it, run validate(), and report back if invalid. If any are invalid, it intercepts the submit and focuses the first invalid field. On form reset, it resets the fields. novalidate is injected automatically by ln-validate — scoped forms never need to write it by hand. |
ln-autosave | None directly. Restores values via populateForm(), dispatches synthetic input/change. ln-validate's listeners catch them and validate. Pure platform event flow. |
ln-confirm | Independent. Confirm gates the click on a destructive button; validate operates on form fields. Different elements, different event channels. |
ln-store | None directly. ln-store dispatches ln-form:error on the form for 4xx server responses. ln-validate does NOT subscribe — the application or a future form-coordinator translates server errors into ln-validate:set-custom dispatches. The injection point is set-custom; the producer is consumer code. |
ln-autoresize | Indirectly. Both listen to input on textareas. They don't coordinate — they happen to react to the same trigger. A <textarea data-ln-validate data-ln-autoresize> resizes and validates simultaneously on every keystroke. |
The pattern is consistent: ln-validate writes to the platform (classes, .hidden toggles, custom events) and reads from the platform (input, change, browser validity). Other components compose by doing the same. None of them import each other.
Common mistakes
1. Skipping the .form-element wrapper
validate() uses dom.closest('.form-element') — null returns short-circuit the error-list lookup. CSS classes on the field still toggle, but no message renders.
2. Programmatic el.value = '...' writes don't validate
Setting .value is silent — neither input nor change fires. Either dispatch the event yourself (mirror the isChangeBased fork) or use lnForm.fill(data) which does the dispatch loop.
3. Listening for :invalid after set-custom
_onSetCustom is intentionally silent at the event level — visible-state changes only, no :invalid dispatch. To make ln-form's submit-button gate learn about custom errors, also call instance.validate() after the dispatch — that path dispatches :invalid, which triggers ln-form to re-evaluate.
4. Relying on the browser's :invalid pseudo-class
CSS :invalid fires on every empty required field on page load — exactly what _touched exists to avoid. Use .ln-validate-invalid instead.
5. Putting data-ln-validate on a non-form element
<div data-ln-validate> crashes on the first event — div.checkValidity() is undefined. Mark only inputs, selects, and textareas.
6. Multiple <li>s with the same key
Both unhide simultaneously — the user sees duplicate messages. Pick one and use a custom-error key for the variant.
For the full list with code samples and explanations, see the README §Common mistakes.