Custom Validation & Validity Messages
apps/docs/src/content/docs/components/forms/custom-validity Click to copy apps/docs/src/content/docs/components/forms/custom-validity Custom Validation & Validity Messages
Section titled “Custom Validation & Validity Messages”The browser’s Constraint Validation API gives every form control — including form-associated custom elements — a standard interface for declaring validity state and communicating error messages to users. HELiX form components are layered on top of that platform contract: they expose the native validity / validationMessage / checkValidity() / reportValidity() surface and an additional HELiX-specific error property/attribute that drives the rendered error UI in shadow DOM. Setting error displays the message; whether the field is constraint-invalid depends on the underlying setValidity() call (which native constraints like required, minlength, etc. drive automatically).
This guide covers the complete customError pattern for custom validation rules that go beyond the standard HTML constraints, asynchronous validators, integrating with Drupal Form API validation, and the accessibility requirements for surfacing error messages to all users.
Reading note: Several recipes below reference components that don’t ship in
@helixui/library(org-patient-id-input,org-username-input) — treat those as consumer-owned patterns and rename them with your own prefix. Some attribute names mentioned in earlier drafts (e.g.error-required,error-too-shortonhx-text-input) are not in the canonical CEM — use theerrorattribute/property with the literal user-facing message instead.
For the foundational concepts — how setValidity() works, what each ValidityState flag means, and when to use checkValidity() vs reportValidity() — see Form Validation Patterns. For how ElementInternals is attached and initialized, see ElementInternals & Form Participation.
The Constraint Validation API at a Glance
Section titled “The Constraint Validation API at a Glance”Every form-associated custom element in HELiX exposes this public interface, which mirrors the API of native form controls:
// Propertiesel.validity; // ValidityState — all constraint flagsel.validationMessage; // string — current error text, '' when validel.willValidate; // boolean — false when disabled or has no name
// Methodsel.checkValidity(); // boolean — silent check, dispatches 'invalid' if falseel.reportValidity(); // boolean — same but also shows browser UIThese properties delegate directly to ElementInternals:
export class HelixTextInput extends LitElement { static formAssociated = true; private _internals: ElementInternals;
constructor() { super(); this._internals = this.attachInternals(); }
get validity(): ValidityState { return this._internals.validity; } get validationMessage(): string { return this._internals.validationMessage; } checkValidity(): boolean { return this._internals.checkValidity(); } reportValidity(): boolean { return this._internals.reportValidity(); }}The willValidate property is managed entirely by the browser. You do not set it — the browser sets it to false automatically when the element is disabled, has no name, or is otherwise excluded from constraint validation.
The ValidityState Object and Its Flags
Section titled “The ValidityState Object and Its Flags”ValidityState is a read-only snapshot of the element’s constraint state. Each property is a boolean. The valid property is true only when every other property is false.
Flag reference
Section titled “Flag reference”| Flag | Triggered when |
|---|---|
valueMissing | required is set and the value is empty |
typeMismatch | Value doesn’t conform to the declared type (e.g., type="email" with a non-email string) |
patternMismatch | Value doesn’t match the pattern regex |
tooShort | Value length is less than minlength |
tooLong | Value length exceeds maxlength |
rangeUnderflow | Numeric or date value is less than min |
rangeOverflow | Numeric or date value exceeds max |
stepMismatch | Numeric value doesn’t conform to step |
badInput | Browser cannot parse the raw input into a valid value |
customError | A custom validation rule you set using setValidity({ customError: true }, ...) |
valid | All other flags are false — the element is fully valid |
Reading flags in consumer code
Section titled “Reading flags in consumer code”const input = document.querySelector('hx-text-input');
// Check a specific flagif (input.validity.valueMissing) { console.log('The field is empty but required');}
// Check overall validityif (!input.validity.valid) { console.log('Error:', input.validationMessage);}
// Read the full state at onceconst { valueMissing, tooShort, tooLong, customError, valid } = input.validity;Reading flags inside the component
Section titled “Reading flags inside the component”Components use their own _internals.validity to decide what to render:
private _renderErrorMessage(): string { const { valueMissing, tooShort, patternMismatch, customError } = this._internals.validity;
if (valueMissing) return 'This field is required.'; if (tooShort) return `Please enter at least ${this.minlength} characters.`; if (patternMismatch) return this._patternError ?? 'Value does not match the required format.'; if (customError) return this._internals.validationMessage;
return '';}setValidity() vs setCustomValidity() on Native Elements
Section titled “setValidity() vs setCustomValidity() on Native Elements”Native elements (<input>, <textarea>, etc.) have a setCustomValidity() method that accepts a string. Passing a non-empty string marks the element invalid with customError: true. Passing an empty string clears the custom error.
// Native input — setCustomValidity()const nativeInput = document.querySelector('input');nativeInput.setCustomValidity('Username already taken.');// nativeInput.validity.customError === true// nativeInput.validationMessage === 'Username already taken.'
nativeInput.setCustomValidity(''); // Clears the custom errorCustom elements use ElementInternals.setValidity() instead. It is more powerful because it lets you set any combination of ValidityStateFlags, not just customError:
// Custom element — ElementInternals.setValidity()this._internals.setValidity( { customError: true }, 'Username already taken.', this._input, // Anchor for browser tooltip);
// Clear itthis._internals.setValidity({});You can also set native constraint flags that native inputs set automatically — valueMissing, tooShort, etc. — and provide your own error message for them:
// Use valueMissing (not customError) so aria-required and :invalid matchthis._internals.setValidity( { valueMissing: true }, 'Please enter the patient's date of birth.', this._input,);When to use customError vs a native flag
Section titled “When to use customError vs a native flag”Use a native flag (valueMissing, tooShort, patternMismatch, etc.) when:
- Your validation logic maps directly to an HTML constraint
- You want CSS selectors like
:invalid:requiredto match correctly - You want framework integrations (React Hook Form, etc.) to read the right flag
Use customError when:
- Your validation rule has no native equivalent
- Validation is asynchronous (server-side checks)
- Validation is cross-field (comparing two inputs)
- The error is context-dependent and cannot be expressed as a constraint attribute
Implementing Custom Validation
Section titled “Implementing Custom Validation”Synchronous custom rules
Section titled “Synchronous custom rules”Use customError for any rule that goes beyond the standard HTML constraints:
export class HelixPatientIdInput extends LitElement { static formAssociated = true; private _internals: ElementInternals;
@property({ type: String }) value = '';
@property({ type: Boolean, reflect: true }) required = false;
@query('input') private _input!: HTMLInputElement;
constructor() { super(); this._internals = this.attachInternals(); }
private _updateValidity(): void { // 1. Required check first — valueMissing takes priority if (this.required && !this.value) { this._internals.setValidity({ valueMissing: true }, 'Patient ID is required.', this._input); return; }
// 2. Custom: MRN must match the P-NNNNN format if (this.value && !/^P-\d{5}$/.test(this.value)) { this._internals.setValidity( { customError: true }, 'Patient ID must be in the format P-12345.', this._input, ); return; }
// 3. Valid — always clear explicitly this._internals.setValidity({}); }
override updated(changedProperties: Map<string, unknown>): void { super.updated(changedProperties); if (changedProperties.has('value') || changedProperties.has('required')) { this._internals.setFormValue(this.value); this._updateValidity(); } }
get validity(): ValidityState { return this._internals.validity; } get validationMessage(): string { return this._internals.validationMessage; } checkValidity(): boolean { return this._internals.checkValidity(); } reportValidity(): boolean { return this._internals.reportValidity(); }
formResetCallback(): void { this.value = ''; this._internals.setFormValue(''); }
formStateRestoreCallback(state: string): void { this.value = state; } override focus(options?: FocusOptions): void { this._input?.focus(options); }}Multiple custom rules
Section titled “Multiple custom rules”Check rules in priority order and return after the first match. The browser displays one error at a time — ValidityState is designed around showing the single most relevant constraint:
private _updateValidity(): void { // Priority 1: required if (this.required && !this.value) { this._internals.setValidity( { valueMissing: true }, 'Password is required.', this._input, ); return; }
if (!this.value) { this._internals.setValidity({}); return; }
// Priority 2: minimum length if (this.value.length < 12) { this._internals.setValidity( { tooShort: true }, `Password must be at least 12 characters (currently ${this.value.length}).`, this._input, ); return; }
// Priority 3: custom — must contain uppercase if (!/[A-Z]/.test(this.value)) { this._internals.setValidity( { customError: true }, 'Password must contain at least one uppercase letter.', this._input, ); return; }
// Priority 4: custom — must contain a digit if (!/\d/.test(this.value)) { this._internals.setValidity( { customError: true }, 'Password must contain at least one number.', this._input, ); return; }
// Priority 5: custom — must contain a special character if (!/[^A-Za-z0-9]/.test(this.value)) { this._internals.setValidity( { customError: true }, 'Password must contain at least one special character.', this._input, ); return; }
this._internals.setValidity({});}Writing Async Validators
Section titled “Writing Async Validators”Some validation rules require a round-trip to the server: checking username uniqueness, verifying NPI numbers, validating insurance policy IDs. The pattern is the same as synchronous validation — you call setValidity() — but you call it asynchronously after an await.
Basic async validator
Section titled “Basic async validator”export class HelixNpiInput extends LitElement { static formAssociated = true; private _internals: ElementInternals;
@property({ type: String }) value = '';
@state() private _isValidating = false;
@query('input') private _input!: HTMLInputElement;
constructor() { super(); this._internals = this.attachInternals(); }
private async _validateNpi(npi: string): Promise<void> { // Basic format check first (synchronous — no network call needed) if (!/^\d{10}$/.test(npi)) { this._internals.setValidity( { patternMismatch: true }, 'NPI must be exactly 10 digits.', this._input, ); return; }
this._isValidating = true; this.requestUpdate();
try { const response = await fetch(`/api/validate-npi?npi=${encodeURIComponent(npi)}`);
if (!response.ok) { // Network error — fail open (do not block submission) this._internals.setValidity({}); return; }
const { valid, message } = (await response.json()) as { valid: boolean; message?: string; };
if (!valid) { this._internals.setValidity( { customError: true }, message ?? 'This NPI number is not registered.', this._input, ); } else { this._internals.setValidity({}); } } catch { // Network failure — fail open this._internals.setValidity({}); } finally { this._isValidating = false; this.requestUpdate(); } }
private _handleBlur(): void { if (this.value) { void this._validateNpi(this.value); } }}Cancelling in-flight requests
Section titled “Cancelling in-flight requests”When the user types quickly, multiple validation requests can be in-flight simultaneously. The last to resolve wins, which can show stale results. Use AbortController to cancel superseded requests:
export class HelixUsernameInput extends LitElement { static formAssociated = true; private _internals: ElementInternals;
@property({ type: String }) value = '';
@state() private _isValidating = false;
@query('input') private _input!: HTMLInputElement;
private _abortController: AbortController | null = null;
constructor() { super(); this._internals = this.attachInternals(); }
private async _validateUsername(username: string): Promise<void> { // Cancel any in-flight request from a previous call this._abortController?.abort(); this._abortController = new AbortController();
// Synchronous checks first if (username.length < 3) { this._internals.setValidity( { tooShort: true }, 'Username must be at least 3 characters.', this._input, ); return; }
if (!/^[a-z0-9_-]+$/i.test(username)) { this._internals.setValidity( { patternMismatch: true }, 'Username may only contain letters, numbers, hyphens, and underscores.', this._input, ); return; }
this._isValidating = true;
try { const response = await fetch(`/api/check-username?username=${encodeURIComponent(username)}`, { signal: this._abortController.signal, });
const { available } = (await response.json()) as { available: boolean };
if (!available) { this._internals.setValidity( { customError: true }, 'This username is already taken.', this._input, ); } else { this._internals.setValidity({}); } } catch (err) { // Swallow AbortError — it's intentional if (err instanceof DOMException && err.name === 'AbortError') return;
// Other errors: fail open (do not block submission) console.error('Username validation failed:', err); this._internals.setValidity({}); } finally { this._isValidating = false; } }
// Debounced handler — validate 400ms after the user stops typing private _debounceTimer: ReturnType<typeof setTimeout> | null = null;
private _handleInput(e: Event): void { this.value = (e.target as HTMLInputElement).value; this._internals.setFormValue(this.value);
if (this._debounceTimer !== null) { clearTimeout(this._debounceTimer); }
this._debounceTimer = setTimeout(() => { void this._validateUsername(this.value); }, 400); }
override disconnectedCallback(): void { super.disconnectedCallback(); // Clean up on removal this._abortController?.abort(); if (this._debounceTimer !== null) clearTimeout(this._debounceTimer); }}Optimistic validation for async rules
Section titled “Optimistic validation for async rules”A useful pattern for async validators: mark the field as invalid with a “pending” message while validation is in-flight. This prevents form submission while the check is running:
private async _validateNpi(npi: string): Promise<void> { // Mark as "pending" — this blocks form submission during the request this._internals.setValidity( { customError: true }, 'Validating NPI…', this._input, ); this._isValidating = true;
try { const response = await fetch(`/api/validate-npi?npi=${npi}`); const { valid, message } = await response.json();
if (!valid) { this._internals.setValidity( { customError: true }, message ?? 'NPI not found in registry.', this._input, ); } else { this._internals.setValidity({}); } } catch { // On error: clear the pending state to allow submission this._internals.setValidity({}); } finally { this._isValidating = false; this.requestUpdate(); }}Integrating with Drupal Form API Validation Messages
Section titled “Integrating with Drupal Form API Validation Messages”Drupal’s Form API validates form submissions server-side and returns structured error messages. In a Drupal-rendered page, these errors may arrive as:
- Inline HTML rendered into the DOM by Drupal (most common)
- AJAX JSON responses containing error lists
- Twig-rendered attributes like
data-drupal-error
HELiX components provide an error attribute and an error slot to accommodate all three patterns.
Pattern 1: Drupal renders the error inline (recommended)
Section titled “Pattern 1: Drupal renders the error inline (recommended)”In Drupal’s Twig template, use the error slot to pass Drupal-rendered markup directly into the component:
{# In your Twig template #}<hx-text-input name="{{ element['#name'] }}" label="{{ element['#title'] }}" {% if element['#required'] %}required{% endif %}> {% if errors %} <div slot="error">{{ errors }}</div> {% endif %}</hx-text-input>The error slot accepts arbitrary HTML, including Drupal’s translated, formatted error strings. The component detects slotted error content and applies the error state visually and via ARIA automatically.
Pattern 2: The error attribute (string values)
Section titled “Pattern 2: The error attribute (string values)”For errors delivered as plain strings — from AJAX responses, JavaScript validation, or simple Twig conditions — use the error attribute:
<hx-text-input name="{{ element['#name'] }}" label="{{ element['#title'] }}" {% if element['#errors'] %} error="{{ element['#errors']|render|striptags|trim }}" {% endif %}></hx-text-input>From JavaScript after an AJAX form validation response:
// Drupal AJAX commands often return error messages as stringsDrupal.behaviors.formValidation = { attach(context) { const form = context.querySelector('form'); if (!form) return;
form.addEventListener('submit', async (e) => { e.preventDefault();
const response = await fetch(form.action, { method: 'POST', body: new FormData(form), headers: { 'X-Requested-With': 'XMLHttpRequest' }, });
const data = await response.json();
if (data.errors) { for (const [fieldName, message] of Object.entries(data.errors)) { const el = form.querySelector(`[name="${fieldName}"]`); if (el && 'error' in el) { (el as HelixTextInput).error = String(message); } } } }); },};Pattern 3: Synchronizing ElementInternals with server errors
Section titled “Pattern 3: Synchronizing ElementInternals with server errors”When server-side errors arrive, synchronize them with the component’s ElementInternals so that checkValidity() and reportValidity() reflect the server’s response:
// A utility function for applying Drupal AJAX validation errors to HELiX controlsfunction applyServerErrors(form: HTMLFormElement, errors: Record<string, string>): void { for (const [fieldName, message] of Object.entries(errors)) { const el = form.querySelector<HTMLElement>(`[name="${CSS.escape(fieldName)}"]`); if (!el) continue;
// Set the error attribute so the component displays the message el.setAttribute('error', message);
// If the element has the standard validation API, also mark it invalid // so form.checkValidity() returns false if ('checkValidity' in el && '_internals' in el) { // Prefer the public error attribute — the component's _updateValidity() // will call setValidity() internally when the attribute changes. // For components without that wiring, use reportValidity() to surface it: if (!el.checkValidity()) { el.reportValidity(); } } }}Pattern 4: Drupal field states (field_ui dependent)
Section titled “Pattern 4: Drupal field states (field_ui dependent)”Drupal’s field states API (data-drupal-states) can show/hide or require fields dynamically. HELiX components respond to required and disabled attribute changes, so Drupal’s states JS works without modification:
<!-- Drupal States: make this field required if another field has a value --><hx-text-input name="referring-physician" label="Referring Physician" data-drupal-states='{"required": {":input[name=\"referral-type\"]": {"value": "external"}}}'></hx-text-input>When Drupal States adds required="required" to the element, the HELiX component’s required property is updated by its attribute observer (@property({ type: Boolean, reflect: true })), and _updateValidity() runs on the next Lit update cycle.
Displaying Errors Accessibly
Section titled “Displaying Errors Accessibly”An error message that only changes a red border is useless for users who cannot see. Every validation error must be communicated through accessible markup.
aria-invalid
Section titled “aria-invalid”Set aria-invalid="true" on the focusable input when the control has an error. Remove it (do not set it to "false") when the control is valid:
import { nothing } from 'lit';
// In render():html` <input aria-invalid=${this.error ? 'true' : nothing} ... /> `;Using nothing from Lit is important. Setting aria-invalid="false" is technically valid, but some screen readers announce “invalid: false” on every focus, which is noisy. Omitting the attribute entirely is cleaner for valid fields.
aria-describedby
Section titled “aria-describedby”Point the input to the error container using aria-describedby. Screen readers read the description when the user focuses the field:
private _errorId = `${this._inputId}-error`;
// In render():html` <input aria-describedby=${ifDefined(this.error ? this._errorId : undefined)} ... />
${this.error ? html`<div id=${this._errorId} class="field__error" ...>${this.error}</div>` : nothing}`If the field also has help text, include both IDs in aria-describedby:
const describedBy = [this.error ? this._errorId : null, this.helpText ? this._helpTextId : null] .filter(Boolean) .join(' ') || undefined;
// In render():html`<input aria-describedby=${ifDefined(describedBy)} ... />`;The order matters: put the error ID first. Screen readers read aria-describedby items in order, and the error is more urgent than help text.
role=“alert” and aria-live
Section titled “role=“alert” and aria-live”When an error message appears or changes dynamically, screen readers must announce it without requiring the user to navigate to it:
html` ${this.error ? html` <div class="field__error" id=${this._errorId} role="alert" aria-live="polite"> ${this.error} </div> ` : nothing}`;role="alert" combined with aria-live="polite" causes the screen reader to announce the message after the current speech finishes. Use aria-live="assertive" only for truly critical errors — it interrupts the user immediately.
Important: The element with role="alert" must exist in the DOM before content is injected into it. If you add and remove the entire element, screen readers may not announce it reliably in all browsers. The safer pattern is to keep an always-present container and set its content:
// Less reliable: element is conditionally added and removed${this.error ? html`<div role="alert">${this.error}</div>` : nothing}
// More reliable: container always present, content changesrender() { return html` <div class="field__error" id=${this._errorId} role="alert" aria-live="polite" aria-atomic="true" > ${this.error} </div> `;}aria-atomic="true" tells screen readers to read the entire container content as a unit when any part changes, rather than announcing only the changed text.
aria-required
Section titled “aria-required”Mark required fields so screen readers announce them without relying on visual cues:
html` <input aria-required=${this.required ? 'true' : nothing} ... /> `;The visual asterisk should be aria-hidden="true" to prevent double-announcement:
html` <label class="field__label" for=${this._inputId}> ${this.label} ${this.required ? html`<span class="field__required-marker" aria-hidden="true">*</span>` : nothing} </label>`;Complete accessible error pattern
Section titled “Complete accessible error pattern”This is the pattern used in hx-text-input:
override render() { const hasError = !!this.error; const describedBy = [ hasError ? this._errorId : null, this.helpText && !hasError ? this._helpTextId : null, ].filter(Boolean).join(' ') || undefined;
return html` <div class="field ${hasError ? 'field--error' : ''}"> <label class="field__label" for=${this._inputId}> ${this.label} ${this.required ? html`<span class="field__required-marker" aria-hidden="true">*</span>` : nothing} </label>
<input class="field__input" id=${this._inputId} .value=${live(this.value)} ?required=${this.required} ?disabled=${this.disabled} aria-required=${this.required ? 'true' : nothing} aria-invalid=${hasError ? 'true' : nothing} aria-describedby=${ifDefined(describedBy)} @input=${this._handleInput} />
<div class="field__error" id=${this._errorId} role="alert" aria-live="polite" aria-atomic="true" > ${this.error} </div>
${this.helpText && !hasError ? html`<div class="field__help-text" id=${this._helpTextId}>${this.helpText}</div>` : nothing} </div> `;}Live Validation vs On-Submit Validation
Section titled “Live Validation vs On-Submit Validation”When validation runs determines how intrusive the experience is. There is no universally correct answer — different inputs and healthcare workflows call for different strategies.
Strategy 1: Validate on blur (preferred for most fields)
Section titled “Strategy 1: Validate on blur (preferred for most fields)”The field validates after the user leaves it. Until then, no errors are shown.
export class HelixTextInput extends LitElement { @state() private _hasBlurred = false;
private _handleBlur(): void { this._hasBlurred = true; this._updateValidity(); this._showError = !this._internals.validity.valid; this.requestUpdate(); }
override updated(changedProperties: Map<string, unknown>): void { super.updated(changedProperties); if (changedProperties.has('value')) { this._internals.setFormValue(this.value); this._updateValidity();
// Only update displayed error if the user has already blurred once if (this._hasBlurred) { this._showError = !this._internals.validity.valid; this.requestUpdate(); } } }}Appropriate for: Most text inputs, date fields, selects. Non-intrusive — doesn’t startle users mid-entry.
Strategy 2: Validate on change (after first blur)
Section titled “Strategy 2: Validate on change (after first blur)”After the user has left the field once, validate on every keystroke so errors clear as soon as the constraint is satisfied:
export class HelixTextInput extends LitElement { @state() private _touched = false;
private _handleBlur(): void { this._touched = true; this._updateAndShowValidity(); }
private _handleInput(e: Event): void { this.value = (e.target as HTMLInputElement).value; this._internals.setFormValue(this.value); this._updateValidity();
// Show errors immediately only after first blur if (this._touched) { this._updateAndShowValidity(); } }
private _updateAndShowValidity(): void { this._updateValidity(); this.error = this._internals.validationMessage; }}Appropriate for: Password inputs (where the requirement is complex and the user needs real-time feedback), MRN fields with specific formats.
Strategy 3: Validate on submit only
Section titled “Strategy 3: Validate on submit only”Errors appear only when the user attempts to submit the form. The simplest strategy — the browser handles everything:
<form> <hx-text-input name="mrn" required></hx-text-input> <button type="submit">Register</button></form><!-- Clicking Submit: browser calls checkValidity() on hx-text-input. If false, browser shows validation UI via reportValidity(). -->No additional code is needed for this to work. Because hx-text-input is properly form-associated and calls setValidity(), the browser’s built-in form submission validation handles it.
Appropriate for: Short forms, administrative workflows, situations where you want to replicate the native form feel exactly.
Strategy 4: Validate on submit, then on change
Section titled “Strategy 4: Validate on submit, then on change”Combines strategies 2 and 3: no errors shown until submission is attempted, then errors clear as the user types:
export class HelixForm extends LitElement { @state() private _submitted = false;
private _handleSubmit(e: Event): void { e.preventDefault(); this._submitted = true;
// Show errors on all controls by reading validity and setting the error attribute const controls = this.querySelectorAll<HelixTextInput>('hx-text-input'); let firstInvalid: HelixTextInput | null = null;
for (const control of controls) { if (!control.checkValidity()) { control.error = control.validationMessage; firstInvalid ??= control; } }
if (firstInvalid) { firstInvalid.focus(); return; }
void this._submit(); }}Appropriate for: Long wizard-style forms, multi-step intake forms, anywhere that showing errors eagerly would overwhelm the user.
Testing Custom Validation
Section titled “Testing Custom Validation”Vitest browser mode tests should cover the full range of validity states for every constraint your component implements.
Test structure
Section titled “Test structure”import { describe, it, expect, afterEach } from 'vitest';// HELiX tests use the shared test-utils helpers, not @open-wc/testing// (which isn't a workspace dependency). For per-component test fixtures// inside the monorepo:import { fixture, cleanup } from '../../test-utils.js';import type { HelixPatientIdInput } from './org-patient-id-input.js';
describe('org-patient-id-input validation', () => { afterEach(cleanup);
// Required constraint it('is invalid when required and empty', async () => { const el = await fixture<HelixPatientIdInput>( html`<org-patient-id-input required></org-patient-id-input>`, );
expect(el.checkValidity()).toBe(false); expect(el.validity.valueMissing).toBe(true); expect(el.validationMessage).toBe('Patient ID is required.'); });
// Custom format constraint it('is invalid when value does not match the P-NNNNN format', async () => { const el = await fixture<HelixPatientIdInput>( html`<org-patient-id-input value="12345"></org-patient-id-input>`, );
expect(el.checkValidity()).toBe(false); expect(el.validity.customError).toBe(true); expect(el.validationMessage).toBe('Patient ID must be in the format P-12345.'); });
it('is valid when value matches the P-NNNNN format', async () => { const el = await fixture<HelixPatientIdInput>( html`<org-patient-id-input value="P-00441"></org-patient-id-input>`, );
expect(el.checkValidity()).toBe(true); expect(el.validity.valid).toBe(true); expect(el.validationMessage).toBe(''); });
// Validity clearing it('clears validity when value becomes valid', async () => { const el = await fixture<HelixPatientIdInput>( html`<org-patient-id-input required value="bad"></org-patient-id-input>`, );
expect(el.checkValidity()).toBe(false);
el.value = 'P-00441'; await el.updateComplete;
expect(el.checkValidity()).toBe(true); });
// Form integration it('blocks form submission when invalid', async () => { const form = await fixture<HTMLFormElement>(html` <form> <org-patient-id-input name="pid" required></org-patient-id-input> </form> `);
expect(form.checkValidity()).toBe(false); });
it('allows form submission when valid', async () => { const form = await fixture<HTMLFormElement>(html` <form> <org-patient-id-input name="pid" value="P-00441"></org-patient-id-input> </form> `);
expect(form.checkValidity()).toBe(true); });
// FormData integration it('appears in FormData when valid', async () => { const form = await fixture<HTMLFormElement>(html` <form> <org-patient-id-input name="pid" value="P-00441"></org-patient-id-input> </form> `);
const formData = new FormData(form); expect(formData.get('pid')).toBe('P-00441'); });
// ARIA integration it('sets aria-invalid when invalid', async () => { const el = await fixture<HelixPatientIdInput>( html`<org-patient-id-input required></org-patient-id-input>`, );
const input = el.shadowRoot!.querySelector('input')!; // Trigger error display el.error = el.validationMessage; await el.updateComplete;
expect(input.getAttribute('aria-invalid')).toBe('true'); });});Testing async validators
Section titled “Testing async validators”Use vi.fn() to mock fetch so tests are deterministic and do not hit the network:
import { vi, describe, it, expect, afterEach, beforeEach } from 'vitest';
describe('org-username-input async validation', () => { beforeEach(() => { vi.spyOn(globalThis, 'fetch'); });
afterEach(() => { vi.restoreAllMocks(); cleanup(); });
it('sets customError when username is taken', async () => { vi.mocked(fetch).mockResolvedValueOnce( new Response(JSON.stringify({ available: false }), { status: 200 }), );
const el = await fixture<HelixUsernameInput>( html`<org-username-input value="alice"></org-username-input>`, );
// Trigger validation await el.validateUsername('alice');
expect(el.checkValidity()).toBe(false); expect(el.validity.customError).toBe(true); expect(el.validationMessage).toBe('This username is already taken.'); });
it('is valid when username is available', async () => { vi.mocked(fetch).mockResolvedValueOnce( new Response(JSON.stringify({ available: true }), { status: 200 }), );
const el = await fixture<HelixUsernameInput>( html`<org-username-input value="alice"></org-username-input>`, );
await el.validateUsername('alice');
expect(el.checkValidity()).toBe(true); });
it('fails open on network error', async () => { vi.mocked(fetch).mockRejectedValueOnce(new Error('Network failure'));
const el = await fixture<HelixUsernameInput>( html`<org-username-input value="alice"></org-username-input>`, );
await el.validateUsername('alice');
// Should be valid (fail open) — do not block submission on network errors expect(el.checkValidity()).toBe(true); });});Validation Message Writing Guide
Section titled “Validation Message Writing Guide”Error messages in healthcare applications carry significant weight. A vague or confusing error on a medication order form can cause real harm. Write messages as if the user’s health depends on reading them correctly — because sometimes it does.
Principles
Section titled “Principles”Be specific. Tell the user exactly what is wrong and what is needed:
// Wrong"Invalid."
// Correct"Please enter a 10-digit NPI number."Include context. Dynamic messages that include what the user entered or the exact constraint are more helpful than static strings:
// Wrong'Value is too short.'// Correct`Please enter at least ${this.minlength} characters. You entered ${this.value.length}.`;Use plain language. No technical jargon, no constraint flag names:
// Wrong"patternMismatch: /^P-\d{5}$/"
// Correct"Patient ID must be in the format P-12345."State the solution, not the problem. Lead with what to do, not what went wrong:
// Passive"Date of birth is required."
// Actionable"Please enter the patient's date of birth."Match the platform. When your validation mirrors a native constraint, match the browser’s message style. It reduces cognitive friction for users familiar with the browser’s built-in validation:
| Constraint | Browser message (Chromium) | HELiX convention |
|---|---|---|
required empty | ”Please fill out this field." | "This field is required.” |
type="email" bad value | ”Please enter an email address." | "Please enter a valid email address.” |
minlength | ”Please lengthen this text to N characters or more (you are currently using N characters)." | "Please enter at least N characters (you entered N).” |
Internationalisation
Section titled “Internationalisation”If the application supports multiple languages, validation messages must be internationalised. The shipped hx-text-input does not expose error-required / error-too-short attributes — it has a single error attribute/property that drives the rendered message, and constraint-specific messages are computed by the component’s internal _updateValidity() against the active constraint (with the literal translated string passed to setValidity()’s second argument).
Drupal templates supply the translated string by setting error directly, or by computing the message in PHP/preprocess before rendering the template:
{% if violation %} <hx-text-input name="{{ element['#name'] }}" required error="{{ violation.message }}" ></hx-text-input>{% else %} <hx-text-input name="{{ element['#name'] }}" required></hx-text-input>{% endif %}For a consumer-built form control that wants typed-attribute-per-constraint i18n, you can define your own error-required / error-too-short pattern on top of LitElement — but use a non-hx- prefix so the contract is clearly yours:
// Consumer-owned org-text-input that exposes per-constraint i18n attributes.@property({ type: String, attribute: 'error-required' })errorRequired = 'This field is required.';
@property({ type: String, attribute: 'error-too-short' })errorTooShort = '';
private _updateValidity(): void { if (this.required && !this.value) { this._internals.setValidity( { valueMissing: true }, this.errorRequired, this._input, ); return; }
if (this.minlength && this.value.length < this.minlength) { const msg = this.errorTooShort || `Please enter at least ${this.minlength} characters.`;
this._internals.setValidity({ tooShort: true }, msg, this._input); return; }
this._internals.setValidity({});}References
Section titled “References”- MDN: Constraint validation
- MDN: ValidityState
- MDN: ElementInternals.setValidity()
- MDN: HTMLElement.setCustomValidity()
- MDN: aria-invalid
- WCAG 2.2 SC 1.3.1 Info and Relationships
- WCAG 2.2 SC 3.3.1 Error Identification
- WCAG 2.2 SC 3.3.3 Error Suggestion
- ElementInternals & Form Participation
- Form Validation Patterns
- Form Accessibility