FormValidator takes over the form's submit event. Constructing one is how you hook a submit, so reach for it even when the form has no validation rules, and do not add a submit listener of your own. On top of that it adds HTML5 validation, error summaries, and custom rules.
import { FormValidator } from '@relax.js/core/forms';
const form = document.querySelector('form');
const validator = new FormValidator(form, {
submitCallback: () => saveData()
});For the full shape of a page built this way, see Building a Form Page.
interface ValidatorOptions {
autoValidate?: boolean; // Validate on every input event
useSummary?: boolean; // Show errors in summary element
customChecks?: (form: HTMLFormElement) => void; // Custom validation
preventDefault?: boolean; // Always prevent form submission
preventDefaultOnFailed?: boolean; // Prevent submission on failure (default: true)
submitCallback?: () => void | Promise<void>; // Awaited when the form is valid
}The browser's own submit, the one that posts the form and navigates, is suppressed in these cases:
| Options | Valid form | Invalid form |
|---|---|---|
submitCallback set |
Suppressed, callback runs | Suppressed, callback skipped |
preventDefault: true |
Suppressed | Suppressed |
preventDefaultOnFailed: false |
Native submit happens | Native submit happens |
| None of the above | Native submit happens | Suppressed |
Supplying a submitCallback is enough on its own. The suppression happens before validation runs, so the page stays put whether the form passed or failed, and you never need to call preventDefault() yourself. preventDefaultOnFailed only has a say when validation failed.
submitCallback may return a promise and is awaited:
const validator = new FormValidator(form, {
submitCallback: async () => {
const response = await post('/users', JSON.stringify(readData(form)));
if (!response.success) {
validator.addErrorToSummary('Save', `The server rejected the change (${response.statusCode})`);
}
}
});A rejected promise is reported through the global onError handler as submitCallback failed, with the original error in context.cause. It is never swallowed.
Pass an arrow function. The callback is invoked with the validator as its this, so a plain function () {} or a bare method reference sees the validator rather than your component.
Enable real-time validation as users type:
const validator = new FormValidator(form, {
autoValidate: true,
useSummary: true
});Instead of browser tooltips, show errors in a summary element:
const validator = new FormValidator(form, {
useSummary: true,
submitCallback: () => handleSubmit()
});The error summary is automatically created and prepended to the form with:
role="alert"for accessibilityaria-live="assertive"for screen readersclass="error-summary"for styling
Add business logic validation beyond HTML5 constraints:
const validator = new FormValidator(form, {
useSummary: true,
customChecks: (form) => {
const password = form.querySelector('[name="password"]') as HTMLInputElement;
const confirm = form.querySelector('[name="confirmPassword"]') as HTMLInputElement;
if (password.value !== confirm.value) {
validator.addErrorToSummary('Password Confirmation', 'Passwords do not match');
}
const startDate = new Date(form.querySelector('[name="startDate"]').value);
const endDate = new Date(form.querySelector('[name="endDate"]').value);
if (endDate <= startDate) {
validator.addErrorToSummary('End Date', 'Must be after start date');
}
},
submitCallback: () => saveData()
});Trigger validation programmatically:
if (validator.validateForm()) {
// Form is valid
proceedToNextStep();
}Add a single error to the summary:
validator.addErrorToSummary('Email', 'This email is already registered');Display multiple errors at once:
validator.displayErrorSummary([
'Name: This field is required',
'Email: Invalid email format',
'Age: Must be 18 or older'
]);Remove all errors from the summary:
validator.clearErrorSummary();Use FindForm to locate a form relative to a custom element:
class MyComponent extends HTMLElement {
connectedCallback() {
const form = FormValidator.FindForm(this);
new FormValidator(form);
}
}FindForm searches:
- Parent element (if it's a form)
- Descendants, so a form inside the wrapper
compileTemplateproduces is found
Declarative validation rules applied via the data-validate attribute. Multiple rules are space-separated.
<input name="age" type="number" data-validate="required range(0-120)" />Validation messages use the i18n system. Load the r-validation namespace for localized messages:
await loadNamespace('r-validation');Translation keys in the r-validation namespace:
| Key | Message (EN) | Message (SV) |
|---|---|---|
required |
This field is required. |
Detta fält är obligatoriskt. |
range |
Number must be between {min} and {max}, was {actual}. |
Talet måste vara mellan {min} och {max}, var {actual}. |
digits |
Please enter only digits. |
Ange endast siffror. |
Validates that the field has a non-empty value (whitespace-only fails).
<input name="username" data-validate="required" />Validates that a numeric value falls within a range (inclusive). Supports negative numbers and decimals. Empty values are skipped (use with required if the field must be filled).
<input name="age" type="number" data-validate="range(0-120)" />
<input name="temperature" type="number" data-validate="range(-40-50)" />
<input name="ratio" type="number" data-validate="range(0.0-1.0)" />Validates that the value contains only digits (0-9).
<input name="zipCode" data-validate="digits" />Register your own validators using the @RegisterValidator decorator. The class must implement a validate(value, context) method.
import { RegisterValidator, ValidationContext } from '@relax.js/core/forms';
@RegisterValidator('email')
class EmailValidation {
validate(value: string, context: ValidationContext) {
if (value && !value.includes('@')) {
context.addError('Invalid email address');
}
}
}Restrict a validator to specific input types with the second parameter:
@RegisterValidator('positive', ['number'])
class PositiveValidation {
validate(value: string, context: ValidationContext) {
if (value && parseFloat(value) < 0) {
context.addError('Value must be positive');
}
}
}The context object passed to validators:
interface ValidationContext {
inputType: string; // The HTML input type
dataType?: string; // The data-type attribute value
addError(message: string): void; // Report a validation error
}Look up registered validators programmatically:
import { getValidator } from '@relax.js/core/forms';
const entry = getValidator('required');
// entry.validator is the class constructor
// entry.validInputTypes lists the input types this validator applies toclass FileUploadForm {
private maxFileSize = 5 * 1024 * 1024; // 5MB
private allowedTypes = ['image/jpeg', 'image/png', 'application/pdf'];
constructor(form: HTMLFormElement) {
const validator = new FormValidator(form, {
useSummary: true,
customChecks: (form) => this.validateFiles(form, validator),
submitCallback: () => this.handleSubmit()
});
}
validateFiles(form: HTMLFormElement, validator: FormValidator) {
const fileInputs = form.querySelectorAll('input[type="file"]');
fileInputs.forEach(input => {
const files = (input as HTMLInputElement).files;
if (!files) return;
Array.from(files).forEach(file => {
if (file.size > this.maxFileSize) {
validator.addErrorToSummary(
input.name,
`File "${file.name}" is too large (max 5MB)`
);
}
if (!this.allowedTypes.includes(file.type)) {
validator.addErrorToSummary(
input.name,
`File "${file.name}" has invalid type`
);
}
});
});
}
handleSubmit() {
const formData = new FormData(form);
// Upload files...
}
}class RobustFormHandler {
private validator: FormValidator;
constructor(private form: HTMLFormElement) {
this.validator = new FormValidator(form, {
useSummary: true,
customChecks: (form) => this.comprehensiveValidation(form),
submitCallback: () => this.handleSubmissionWithRetry()
});
}
comprehensiveValidation(form: HTMLFormElement) {
try {
this.validateRequiredFields(form);
this.validateDataFormats(form);
this.validateBusinessRules(form);
} catch (error) {
console.error('Validation error:', error);
this.validator.addErrorToSummary('System', 'Validation failed. Please try again.');
}
}
validateDataFormats(form: HTMLFormElement) {
const emails = form.querySelectorAll('input[type="email"]');
emails.forEach(input => {
if (input.value && !this.isValidEmail(input.value)) {
this.validator.addErrorToSummary(input.name, 'Invalid email format');
}
});
const phones = form.querySelectorAll('input[data-type="phone"]');
phones.forEach(input => {
if (input.value && !this.isValidPhone(input.value)) {
this.validator.addErrorToSummary(input.name, 'Invalid phone number');
}
});
}
async handleSubmissionWithRetry() {
const maxRetries = 3;
let attempt = 0;
while (attempt < maxRetries) {
try {
const data = readData(this.form);
await this.submitToAPI(data);
this.showSuccess();
return;
} catch (error) {
attempt++;
if (attempt >= maxRetries) {
this.validator.addErrorToSummary('Submission', 'Failed to submit after multiple attempts');
} else {
await this.delay(1000 * attempt);
}
}
}
}
}