Validate

Mode

Skin

Theme

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:

  1. The Browser Engine (Native Constraints): Constraints are declared directly in native HTML attributes (like required, type="email", minlength="5", or pattern="..."). The browser's native C++ engine handles the complex parsing and validation matching instantly.
  2. The Visual Interface (CSS Classes & Errors): Visual state is bound to standard CSS classes: .ln-validate-valid for success and .ln-validate-invalid for failures, while error messages start hidden (.hidden) in a sibling <ul data-ln-validate-errors>.
  3. 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 active ValidityState failures to their corresponding HTML error items, and exposes programmatic methods (like validate() and reset()).

How the Interaction Flows

When a user starts typing in a validated input:

  1. The component intercepts the input or change event and instantly flips the field's internal state to _touched = true.
  2. The component queries dom.checkValidity(). If it fails, it adds .ln-validate-invalid to the input, matches the browser's active ValidityState key (e.g. valueMissing) to the target HTML element (data-ln-validate-error="required"), removes its .hidden class, and dispatches a bubbled ln-validate:invalid CustomEvent.
  3. If validation passes, it cleans up all visual error classes, restores the .hidden state on all message elements, and dispatches a bubbled ln-validate:valid CustomEvent.

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';
isChangeBasedField typesListeners
falsetext, email, tel, url, password, number, search, date, time inputs; textareainput 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-errorHTML constraintValidityState property
requiredrequiredvalueMissing
typeMismatchtype="email", type="url", type="number", etc.typeMismatch
tooShortminlengthtooShort
tooLongmaxlengthtooLong
patternMismatchpatternpatternMismatch
rangeUnderflowminrangeUnderflow
rangeOverflowmaxrangeOverflow

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)

Demo — Email validation

Demo — Select field (change-based)

Demo — Pattern validation

Demo — Number range

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

AttributeOn elementDescription
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

EventCancelabledetailDispatched when
ln-validate:validno{ target, field }After validate() determines the field is valid (native + no custom errors).
ln-validate:invalidno{ 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

EventdetailBehavior
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

ComponentCoordination
ln-formOn 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-autosaveNone directly. Restores values via populateForm(), dispatches synthetic input/change. ln-validate's listeners catch them and validate. Pure platform event flow.
ln-confirmIndependent. Confirm gates the click on a destructive button; validate operates on form fields. Different elements, different event channels.
ln-storeNone 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-autoresizeIndirectly. 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.