Advanced Patterns
Complex form scenarios with multi-step wizards, dynamic fields, conditional validation, and more.
What you'll learn...
Multi-step wizard forms with progress tracking
Dynamic forms (add/remove fields at runtime)
Conditional validation based on field dependencies
Form state management (dirty, pristine, touched)
Autosave and draft management
Complex file uploads with preview and progress
Form arrays (repeating field groups)
Prerequisites
All examples assume you have the validation plugin installed and configured:
npm install @aurelia/validation @aurelia/validation-html// src/main.ts
import Aurelia from 'aurelia';
import { ValidationHtmlConfiguration } from '@aurelia/validation-html';
Aurelia.register(ValidationHtmlConfiguration)
.app(component)
.start();See Validation documentation for setup details.
Multi-Step Wizard Forms
Multi-step forms break complex forms into manageable steps, improving user experience and completion rates.
Complete Example: User Onboarding Wizard
// src/components/onboarding-wizard.ts
import { newInstanceForScope, resolve } from '@aurelia/kernel';
import { IValidationRules } from '@aurelia/validation';
import { IValidationController } from '@aurelia/validation-html';
interface UserProfile {
// Step 1: Account
email: string;
password: string;
confirmPassword: string;
// Step 2: Personal Info
firstName: string;
lastName: string;
dateOfBirth: string;
phone: string;
// Step 3: Preferences
newsletter: boolean;
notifications: boolean;
theme: 'light' | 'dark';
language: string;
}
export class OnboardingWizard {
private currentStep = 1;
private readonly totalSteps = 3;
private profile: UserProfile = {
email: '',
password: '',
confirmPassword: '',
firstName: '',
lastName: '',
dateOfBirth: '',
phone: '',
newsletter: true,
notifications: true,
theme: 'light',
language: 'en'
};
private validation = resolve(newInstanceForScope(IValidationController));
private validationRules = resolve(IValidationRules);
constructor() {
this.setupValidation();
}
private setupValidation() {
// Step 1 validation rules
this.validationRules
.on(this.profile)
.ensure('email')
.required()
.email()
.when(() => this.currentStep === 1)
.ensure('password')
.required()
.minLength(8)
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('Password must contain uppercase, lowercase, and number')
.when(() => this.currentStep === 1)
.ensure('confirmPassword')
.required()
.satisfies((value: string) => value === this.profile.password)
.withMessage('Passwords must match')
.when(() => this.currentStep === 1)
// Step 2 validation rules
.ensure('firstName')
.required()
.minLength(2)
.when(() => this.currentStep === 2)
.ensure('lastName')
.required()
.minLength(2)
.when(() => this.currentStep === 2)
.ensure('dateOfBirth')
.required()
.satisfies((value: string) => {
const age = this.calculateAge(new Date(value));
return age >= 18 && age <= 120;
})
.withMessage('You must be at least 18 years old')
.when(() => this.currentStep === 2)
.ensure('phone')
.required()
.matches(/^\+?[\d\s\-()]+$/)
.withMessage('Please enter a valid phone number')
.when(() => this.currentStep === 2);
}
private calculateAge(birthDate: Date): number {
const today = new Date();
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
return age;
}
async next() {
// Validate current step before proceeding
const result = await this.validation.validate();
if (!result.valid) {
return; // Stay on current step if validation fails
}
if (this.currentStep < this.totalSteps) {
this.currentStep++;
}
}
previous() {
if (this.currentStep > 1) {
this.currentStep--;
}
}
async submit() {
// Validate all steps
const result = await this.validation.validate();
if (!result.valid) {
// Find first step with errors
const firstErrorStep = this.findFirstErrorStep(result.results);
this.currentStep = firstErrorStep;
return;
}
// Submit the form
try {
await this.saveProfile(this.profile);
console.log('Profile saved successfully!');
} catch (error) {
console.error('Failed to save profile:', error);
}
}
private findFirstErrorStep(results: any[]): number {
const step1Fields = ['email', 'password', 'confirmPassword'];
const step2Fields = ['firstName', 'lastName', 'dateOfBirth', 'phone'];
for (const result of results) {
if (!result.valid) {
if (step1Fields.includes(result.propertyName)) return 1;
if (step2Fields.includes(result.propertyName)) return 2;
}
}
return this.currentStep;
}
private async saveProfile(profile: UserProfile): Promise<void> {
// API call to save profile
const response = await fetch('/api/onboarding', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(profile)
});
if (!response.ok) {
throw new Error('Failed to save profile');
}
}
get progress(): number {
return (this.currentStep / this.totalSteps) * 100;
}
get isFirstStep(): boolean {
return this.currentStep === 1;
}
get isLastStep(): boolean {
return this.currentStep === this.totalSteps;
}
}<!-- src/components/onboarding-wizard.html -->
<div class="wizard">
<!-- Progress bar -->
<div class="wizard-progress">
<div class="wizard-progress-bar" style="width: ${progress}%"></div>
<div class="wizard-steps">
<div class="wizard-step ${currentStep >= 1 ? 'active' : ''} ${currentStep > 1 ? 'completed' : ''}">
<span class="step-number">1</span>
<span class="step-label">Account</span>
</div>
<div class="wizard-step ${currentStep >= 2 ? 'active' : ''} ${currentStep > 2 ? 'completed' : ''}">
<span class="step-number">2</span>
<span class="step-label">Personal Info</span>
</div>
<div class="wizard-step ${currentStep >= 3 ? 'active' : ''}">
<span class="step-number">3</span>
<span class="step-label">Preferences</span>
</div>
</div>
</div>
<!-- Step 1: Account -->
<div class="wizard-content" if.bind="currentStep === 1">
<h2>Create Your Account</h2>
<div class="form-field">
<label for="email">Email</label>
<input
type="email"
id="email"
value.bind="profile.email & validate"
placeholder="[email protected]">
</div>
<div class="form-field">
<label for="password">Password</label>
<input
type="password"
id="password"
value.bind="profile.password & validate"
placeholder="Min. 8 characters">
</div>
<div class="form-field">
<label for="confirmPassword">Confirm Password</label>
<input
type="password"
id="confirmPassword"
value.bind="profile.confirmPassword & validate">
</div>
</div>
<!-- Step 2: Personal Info -->
<div class="wizard-content" if.bind="currentStep === 2">
<h2>Tell Us About Yourself</h2>
<div class="form-row">
<div class="form-field">
<label for="firstName">First Name</label>
<input
type="text"
id="firstName"
value.bind="profile.firstName & validate">
</div>
<div class="form-field">
<label for="lastName">Last Name</label>
<input
type="text"
id="lastName"
value.bind="profile.lastName & validate">
</div>
</div>
<div class="form-field">
<label for="dateOfBirth">Date of Birth</label>
<input
type="date"
id="dateOfBirth"
value.bind="profile.dateOfBirth & validate">
</div>
<div class="form-field">
<label for="phone">Phone Number</label>
<input
type="tel"
id="phone"
value.bind="profile.phone & validate"
placeholder="+1 (555) 123-4567">
</div>
</div>
<!-- Step 3: Preferences -->
<div class="wizard-content" if.bind="currentStep === 3">
<h2>Customize Your Experience</h2>
<div class="form-field">
<label>
<input type="checkbox" checked.bind="profile.newsletter">
Subscribe to newsletter
</label>
</div>
<div class="form-field">
<label>
<input type="checkbox" checked.bind="profile.notifications">
Enable notifications
</label>
</div>
<div class="form-field">
<label for="theme">Theme</label>
<select id="theme" value.bind="profile.theme">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
<div class="form-field">
<label for="language">Language</label>
<select id="language" value.bind="profile.language">
<option value="en">English</option>
<option value="es">Español</option>
<option value="fr">Français</option>
<option value="de">Deutsch</option>
</select>
</div>
</div>
<!-- Navigation -->
<div class="wizard-actions">
<button
type="button"
click.trigger="previous()"
disabled.bind="isFirstStep"
class="btn btn-secondary">
Previous
</button>
<button
if.bind="!isLastStep"
type="button"
click.trigger="next()"
class="btn btn-primary">
Next
</button>
<button
if.bind="isLastStep"
type="button"
click.trigger="submit()"
class="btn btn-success">
Complete
</button>
</div>
</div>Key Features:
Step-by-step validation (only validate current step)
Progress indicator
Conditional validation rules with
.when()Navigate to first step with errors on final submit
Accessible navigation buttons
Dynamic Forms (Add/Remove Fields)
Forms where users can add or remove fields at runtime, like adding multiple email addresses or phone numbers.
Complete Example: Contact Form with Dynamic Emails
// src/components/dynamic-contact-form.ts
import { newInstanceForScope } from '@aurelia/kernel';
import { IValidationRules } from '@aurelia/validation';
import { IValidationController } from '@aurelia/validation-html';
interface EmailEntry {
id: string;
address: string;
label: string;
isPrimary: boolean;
}
interface ContactForm {
name: string;
company: string;
emails: EmailEntry[];
notes: string;
}
export class DynamicContactForm {
private form: ContactForm = {
name: '',
company: '',
emails: [this.createEmailEntry(true)],
notes: ''
};
private validation = resolve(newInstanceForScope(IValidationController));
private nextId = 1;
private validationRules = resolve(IValidationRules);
constructor() {
this.setupValidation();
}
private createEmailEntry(isPrimary = false): EmailEntry {
return {
id: `email-${this.nextId++}`,
address: '',
label: isPrimary ? 'Primary' : 'Secondary',
isPrimary
};
}
private setupValidation() {
this.validationRules
.on(this.form)
.ensure('name')
.required()
.minLength(2)
.ensure('company')
.required()
.ensure('emails')
.required()
.minItems(1)
.withMessage('At least one email is required')
.satisfies((emails: EmailEntry[]) =>
emails.every(e => this.isValidEmail(e.address))
)
.withMessage('All email addresses must be valid')
.satisfies((emails: EmailEntry[]) =>
emails.filter(e => e.isPrimary).length === 1
)
.withMessage('Exactly one primary email is required');
}
private isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
addEmail() {
this.form.emails.push(this.createEmailEntry());
}
removeEmail(id: string) {
// Don't allow removing the last email
if (this.form.emails.length <= 1) {
return;
}
const index = this.form.emails.findIndex(e => e.id === id);
if (index === -1) return;
const wasRemoved = this.form.emails[index];
this.form.emails.splice(index, 1);
// If we removed the primary, make the first one primary
if (wasRemoved.isPrimary && this.form.emails.length > 0) {
this.form.emails[0].isPrimary = true;
this.form.emails[0].label = 'Primary';
}
// Revalidate after removal
this.validation.validate();
}
setPrimary(id: string) {
// Only one email can be primary
this.form.emails.forEach(email => {
email.isPrimary = email.id === id;
email.label = email.isPrimary ? 'Primary' : 'Secondary';
});
}
async submit() {
const result = await this.validation.validate();
if (!result.valid) {
return;
}
console.log('Form submitted:', this.form);
// API call here
}
get canRemoveEmail(): boolean {
return this.form.emails.length > 1;
}
}<!-- src/components/dynamic-contact-form.html -->
<form submit.trigger="submit()">
<h2>Contact Information</h2>
<div class="form-field">
<label for="name">Name *</label>
<input
type="text"
id="name"
value.bind="form.name & validate">
</div>
<div class="form-field">
<label for="company">Company *</label>
<input
type="text"
id="company"
value.bind="form.company & validate">
</div>
<!-- Dynamic email fields -->
<div class="form-section">
<div class="section-header">
<h3>Email Addresses *</h3>
<button
type="button"
click.trigger="addEmail()"
class="btn btn-small btn-secondary">
+ Add Email
</button>
</div>
<div
repeat.for="email of form.emails"
class="email-entry"
id.bind="email.id">
<div class="form-row">
<div class="form-field flex-grow">
<label for="${email.id}-address">${email.label}</label>
<input
type="email"
id="${email.id}-address"
value.bind="email.address"
placeholder="[email protected]">
</div>
<div class="form-field-actions">
<label class="radio-label">
<input
type="radio"
name="primaryEmail"
checked.bind="email.isPrimary"
change.trigger="setPrimary(email.id)">
Primary
</label>
<button
type="button"
click.trigger="removeEmail(email.id)"
disabled.bind="!canRemoveEmail"
class="btn btn-small btn-danger"
aria-label="Remove email">
×
</button>
</div>
</div>
</div>
</div>
<div class="form-field">
<label for="notes">Notes</label>
<textarea
id="notes"
value.bind="form.notes"
rows="4"></textarea>
</div>
<button type="submit" class="btn btn-primary">Save Contact</button>
</form>Key Features:
Add/remove email entries dynamically
Ensure at least one email exists
Automatically handle primary email when removing
Validate all emails in the array
Unique IDs for each entry (accessibility and key binding)
Conditional Validation (Field Dependencies)
Validation rules that change based on the values of other fields.
Complete Example: Shipping Form
// src/components/shipping-form.ts
import { newInstanceForScope } from '@aurelia/kernel';
import { IValidationRules } from '@aurelia/validation';
import { IValidationController } from '@aurelia/validation-html';
interface ShippingForm {
sameAsBilling: boolean;
// Billing address
billingStreet: string;
billingCity: string;
billingState: string;
billingZip: string;
billingCountry: string;
// Shipping address (only required if different)
shippingStreet: string;
shippingCity: string;
shippingState: string;
shippingZip: string;
shippingCountry: string;
// Shipping method
shippingMethod: 'standard' | 'express' | 'overnight' | '';
// Signature required (only for overnight)
signatureRequired: boolean;
// International customs (only for international)
customsValue: number;
customsDescription: string;
}
export class ShippingFormComponent {
private form: ShippingForm = {
sameAsBilling: true,
billingStreet: '',
billingCity: '',
billingState: '',
billingZip: '',
billingCountry: 'US',
shippingStreet: '',
shippingCity: '',
shippingState: '',
shippingZip: '',
shippingCountry: 'US',
shippingMethod: '',
signatureRequired: false,
customsValue: 0,
customsDescription: ''
};
private validation = resolve(newInstanceForScope(IValidationController));
private validationRules = resolve(IValidationRules);
constructor() {
this.setupValidation();
}
private setupValidation() {
this.validationRules
.on(this.form)
// Billing address (always required)
.ensure('billingStreet')
.required()
.ensure('billingCity')
.required()
.ensure('billingState')
.required()
.ensure('billingZip')
.required()
.matches(/^\d{5}(-\d{4})?$/)
.withMessage('Please enter a valid ZIP code')
.ensure('billingCountry')
.required()
// Shipping address (required only if different from billing)
.ensure('shippingStreet')
.required()
.when(() => !this.form.sameAsBilling)
.ensure('shippingCity')
.required()
.when(() => !this.form.sameAsBilling)
.ensure('shippingState')
.required()
.when(() => !this.form.sameAsBilling)
.ensure('shippingZip')
.required()
.when(() => !this.form.sameAsBilling)
.matches(/^\d{5}(-\d{4})?$/)
.withMessage('Please enter a valid ZIP code')
.when(() => !this.form.sameAsBilling)
.ensure('shippingCountry')
.required()
.when(() => !this.form.sameAsBilling)
// Shipping method
.ensure('shippingMethod')
.required()
.withMessage('Please select a shipping method')
// Customs info (required for international shipments)
.ensure('customsValue')
.required()
.min(0.01)
.withMessage('Customs value must be greater than 0')
.when(() => this.isInternationalShipment)
.ensure('customsDescription')
.required()
.minLength(10)
.withMessage('Please provide a detailed description for customs')
.when(() => this.isInternationalShipment);
}
get isInternationalShipment(): boolean {
const destCountry = this.form.sameAsBilling
? this.form.billingCountry
: this.form.shippingCountry;
return destCountry !== 'US';
}
get isOvernightShipping(): boolean {
return this.form.shippingMethod === 'overnight';
}
sameAsBillingChanged() {
if (this.form.sameAsBilling) {
// Clear shipping address when using billing address
this.form.shippingStreet = '';
this.form.shippingCity = '';
this.form.shippingState = '';
this.form.shippingZip = '';
this.form.shippingCountry = this.form.billingCountry;
}
// Revalidate after toggling
this.validation.validate();
}
async submit() {
const result = await this.validation.validate();
if (!result.valid) {
return;
}
console.log('Shipping form submitted:', this.form);
}
}<!-- src/components/shipping-form.html -->
<form submit.trigger="submit()">
<h2>Billing Address</h2>
<div class="form-field">
<label for="billingStreet">Street Address *</label>
<input
type="text"
id="billingStreet"
value.bind="form.billingStreet & validate">
</div>
<div class="form-row">
<div class="form-field">
<label for="billingCity">City *</label>
<input
type="text"
id="billingCity"
value.bind="form.billingCity & validate">
</div>
<div class="form-field">
<label for="billingState">State *</label>
<input
type="text"
id="billingState"
value.bind="form.billingState & validate">
</div>
<div class="form-field">
<label for="billingZip">ZIP Code *</label>
<input
type="text"
id="billingZip"
value.bind="form.billingZip & validate">
</div>
</div>
<div class="form-field">
<label for="billingCountry">Country *</label>
<select id="billingCountry" value.bind="form.billingCountry & validate">
<option value="US">United States</option>
<option value="CA">Canada</option>
<option value="MX">Mexico</option>
<option value="UK">United Kingdom</option>
<option value="FR">France</option>
</select>
</div>
<hr>
<h2>Shipping Address</h2>
<div class="form-field">
<label>
<input
type="checkbox"
checked.bind="form.sameAsBilling"
change.trigger="sameAsBillingChanged()">
Same as billing address
</label>
</div>
<!-- Only show shipping address fields if different from billing -->
<div if.bind="!form.sameAsBilling">
<div class="form-field">
<label for="shippingStreet">Street Address *</label>
<input
type="text"
id="shippingStreet"
value.bind="form.shippingStreet & validate">
</div>
<div class="form-row">
<div class="form-field">
<label for="shippingCity">City *</label>
<input
type="text"
id="shippingCity"
value.bind="form.shippingCity & validate">
</div>
<div class="form-field">
<label for="shippingState">State *</label>
<input
type="text"
id="shippingState"
value.bind="form.shippingState & validate">
</div>
<div class="form-field">
<label for="shippingZip">ZIP Code *</label>
<input
type="text"
id="shippingZip"
value.bind="form.shippingZip & validate">
</div>
</div>
<div class="form-field">
<label for="shippingCountry">Country *</label>
<select id="shippingCountry" value.bind="form.shippingCountry & validate">
<option value="US">United States</option>
<option value="CA">Canada</option>
<option value="MX">Mexico</option>
<option value="UK">United Kingdom</option>
<option value="FR">France</option>
</select>
</div>
</div>
<hr>
<h2>Shipping Method</h2>
<div class="form-field">
<label for="shippingMethod">Method *</label>
<select id="shippingMethod" value.bind="form.shippingMethod & validate">
<option value="">Select shipping method</option>
<option value="standard">Standard (5-7 days) - $5.99</option>
<option value="express">Express (2-3 days) - $14.99</option>
<option value="overnight">Overnight - $29.99</option>
</select>
</div>
<!-- Only show signature option for overnight shipping -->
<div if.bind="isOvernightShipping" class="form-field">
<label>
<input type="checkbox" checked.bind="form.signatureRequired">
Signature required upon delivery
</label>
</div>
<!-- Only show customs fields for international shipments -->
<div if.bind="isInternationalShipment">
<hr>
<h2>Customs Information</h2>
<div class="form-field">
<label for="customsValue">Declared Value (USD) *</label>
<input
type="number"
id="customsValue"
value.bind="form.customsValue & validate"
step="0.01"
min="0">
</div>
<div class="form-field">
<label for="customsDescription">Description *</label>
<textarea
id="customsDescription"
value.bind="form.customsDescription & validate"
rows="3"
placeholder="Detailed description of contents for customs"></textarea>
</div>
</div>
<button type="submit" class="btn btn-primary">Continue to Payment</button>
</form>Key Features:
Conditional field visibility with
if.bindConditional validation with
.when()Fields depend on checkbox state
Fields depend on select values
Automatic revalidation when dependencies change
Form State Management (Dirty, Pristine, Touched)
Track whether forms have been modified and warn users before losing changes.
Complete Example: Article Editor with Unsaved Changes Warning
// src/components/article-editor.ts
import { IRouter, RouteNode } from '@aurelia/router';
import { newInstanceForScope, resolve } from '@aurelia/kernel';
import { IValidationRules } from '@aurelia/validation';
import { IValidationController } from '@aurelia/validation-html';
interface Article {
id: string | null;
title: string;
content: string;
tags: string[];
published: boolean;
}
export class ArticleEditor {
private article: Article = {
id: null,
title: '',
content: '',
tags: [],
published: false
};
private originalArticle: Article;
private isDirty = false;
private isSaving = false;
private lastSaved: Date | null = null;
private autosaveTimer: any = null;
private validation = resolve(newInstanceForScope(IValidationController));
private router = resolve(IRouter);
private validationRules = resolve(IValidationRules);
constructor() {
this.validationRules
.on(this.article)
.ensure('title')
.required()
.minLength(5)
.ensure('content')
.required()
.minLength(50);
// Store original state
this.originalArticle = JSON.parse(JSON.stringify(this.article));
// Setup autosave
this.setupAutosave();
}
binding() {
// Track changes to mark form as dirty
this.watchForChanges();
}
detaching() {
// Clean up autosave timer
if (this.autosaveTimer) {
clearInterval(this.autosaveTimer);
}
}
private watchForChanges() {
// Simple dirty checking - compare current to original
// In production, consider using a more robust solution
const checkDirty = () => {
this.isDirty = JSON.stringify(this.article) !== JSON.stringify(this.originalArticle);
};
// Check after each property change
// You could use @observable or watch the properties more elegantly
setInterval(checkDirty, 500);
}
private setupAutosave() {
// Autosave every 30 seconds if dirty
this.autosaveTimer = setInterval(() => {
if (this.isDirty && !this.isSaving) {
this.saveDraft();
}
}, 30000);
}
async saveDraft() {
if (this.isSaving) return;
this.isSaving = true;
try {
// Save to localStorage as draft
localStorage.setItem('article-draft', JSON.stringify(this.article));
this.lastSaved = new Date();
console.log('Draft saved');
} catch (error) {
console.error('Failed to save draft:', error);
} finally {
this.isSaving = false;
}
}
async publish() {
const result = await this.validation.validate();
if (!result.valid) {
return;
}
this.isSaving = true;
try {
this.article.published = true;
await this.saveArticle();
// Update original state
this.originalArticle = JSON.parse(JSON.stringify(this.article));
this.isDirty = false;
// Clear draft
localStorage.removeItem('article-draft');
// Navigate away
await this.router.load('/articles');
} catch (error) {
console.error('Failed to publish:', error);
} finally {
this.isSaving = false;
}
}
async save() {
const result = await this.validation.validate();
if (!result.valid) {
return;
}
this.isSaving = true;
try {
this.article.published = false;
await this.saveArticle();
// Update original state
this.originalArticle = JSON.parse(JSON.stringify(this.article));
this.isDirty = false;
// Clear draft
localStorage.removeItem('article-draft');
} catch (error) {
console.error('Failed to save:', error);
} finally {
this.isSaving = false;
}
}
private async saveArticle(): Promise<void> {
const response = await fetch('/api/articles', {
method: this.article.id ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.article)
});
if (!response.ok) {
throw new Error('Failed to save article');
}
const saved = await response.json();
this.article.id = saved.id;
}
// Router lifecycle hook - prevent navigation if dirty
canUnload(next: RouteNode | null, current: RouteNode): boolean {
if (!this.isDirty) {
return true;
}
// Show confirmation dialog
return confirm('You have unsaved changes. Are you sure you want to leave?');
}
get lastSavedText(): string {
if (!this.lastSaved) {
return 'Never saved';
}
const seconds = Math.floor((Date.now() - this.lastSaved.getTime()) / 1000);
if (seconds < 60) {
return 'Saved just now';
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
return `Saved ${minutes} minute${minutes > 1 ? 's' : ''} ago`;
} else {
const hours = Math.floor(seconds / 3600);
return `Saved ${hours} hour${hours > 1 ? 's' : ''} ago`;
}
}
}<!-- src/components/article-editor.html -->
<div class="article-editor">
<!-- Status bar -->
<div class="editor-status">
<span class="status-indicator ${isDirty ? 'dirty' : 'clean'}">
${isDirty ? 'Unsaved changes' : 'All changes saved'}
</span>
<span class="last-saved">${lastSavedText}</span>
<span if.bind="isSaving" class="saving-indicator">Saving...</span>
</div>
<!-- Editor form -->
<div class="form-field">
<label for="title">Title *</label>
<input
type="text"
id="title"
value.bind="article.title & validate"
placeholder="Enter article title">
</div>
<div class="form-field">
<label for="content">Content *</label>
<textarea
id="content"
value.bind="article.content & validate"
rows="20"
placeholder="Write your article..."></textarea>
</div>
<div class="form-field">
<label for="tags">Tags (comma-separated)</label>
<input
type="text"
id="tags"
value.bind="article.tags"
placeholder="javascript, aurelia, web development">
</div>
<!-- Actions -->
<div class="editor-actions">
<button
type="button"
click.trigger="saveDraft()"
disabled.bind="!isDirty || isSaving"
class="btn btn-secondary">
Save Draft
</button>
<button
type="button"
click.trigger="save()"
disabled.bind="isSaving"
class="btn btn-primary">
${isSaving ? 'Saving...' : 'Save'}
</button>
<button
type="button"
click.trigger="publish()"
disabled.bind="isSaving"
class="btn btn-success">
${isSaving ? 'Publishing...' : 'Publish'}
</button>
</div>
</div>Key Features:
Dirty state tracking (compare current vs original)
Autosave to localStorage every 30 seconds
Last saved timestamp with human-readable formatting
Prevent navigation with
canUnloadrouter hookVisual indicators for save state
Disable actions while saving
Form Arrays (Repeating Field Groups)
Form arrays allow users to add/remove entire groups of fields, like invoice line items or multiple addresses.
Complete Example: Invoice Form with Line Items
// src/components/invoice-form.ts
import { newInstanceForScope } from '@aurelia/kernel';
import { IValidationRules } from '@aurelia/validation';
import { IValidationController } from '@aurelia/validation-html';
interface LineItem {
id: string;
description: string;
quantity: number;
unitPrice: number;
total: number;
}
interface Invoice {
invoiceNumber: string;
customerName: string;
customerEmail: string;
invoiceDate: string;
dueDate: string;
items: LineItem[];
subtotal: number;
tax: number;
total: number;
}
export class InvoiceForm {
private invoice: Invoice = {
invoiceNumber: this.generateInvoiceNumber(),
customerName: '',
customerEmail: '',
invoiceDate: new Date().toISOString().split('T')[0],
dueDate: '',
items: [this.createLineItem()],
subtotal: 0,
tax: 0,
total: 0
};
private readonly taxRate = 0.08; // 8% tax
private nextItemId = 1;
private validation = resolve(newInstanceForScope(IValidationController));
private validationRules = resolve(IValidationRules);
constructor() {
this.setupValidation();
}
private generateInvoiceNumber(): string {
return `INV-${Date.now()}`;
}
private createLineItem(): LineItem {
return {
id: `item-${this.nextItemId++}`,
description: '',
quantity: 1,
unitPrice: 0,
total: 0
};
}
private setupValidation() {
this.validationRules
.on(this.invoice)
.ensure('invoiceNumber')
.required()
.ensure('customerName')
.required()
.minLength(2)
.ensure('customerEmail')
.required()
.email()
.ensure('invoiceDate')
.required()
.ensure('dueDate')
.required()
.satisfies((value: string) => {
if (!value || !this.invoice.invoiceDate) return true;
return new Date(value) >= new Date(this.invoice.invoiceDate);
})
.withMessage('Due date must be after invoice date')
.ensure('items')
.required()
.minItems(1)
.withMessage('At least one line item is required')
.satisfies((items: LineItem[]) =>
items.every(item =>
item.description.trim().length > 0 &&
item.quantity > 0 &&
item.unitPrice >= 0
)
)
.withMessage('All line items must be complete');
}
addLineItem() {
this.invoice.items.push(this.createLineItem());
}
removeLineItem(id: string) {
if (this.invoice.items.length <= 1) {
return; // Must have at least one item
}
const index = this.invoice.items.findIndex(item => item.id === id);
if (index !== -1) {
this.invoice.items.splice(index, 1);
this.calculateTotals();
}
}
duplicateLineItem(id: string) {
const index = this.invoice.items.findIndex(item => item.id === id);
if (index !== -1) {
const original = this.invoice.items[index];
const duplicate = {
...original,
id: `item-${this.nextItemId++}`
};
this.invoice.items.splice(index + 1, 0, duplicate);
this.calculateTotals();
}
}
updateLineItem(item: LineItem) {
item.total = item.quantity * item.unitPrice;
this.calculateTotals();
}
private calculateTotals() {
this.invoice.subtotal = this.invoice.items.reduce(
(sum, item) => sum + item.total,
0
);
this.invoice.tax = this.invoice.subtotal * this.taxRate;
this.invoice.total = this.invoice.subtotal + this.invoice.tax;
}
async submit() {
const result = await this.validation.validate();
if (!result.valid) {
return;
}
console.log('Invoice submitted:', this.invoice);
// API call to save invoice
}
get canRemoveItem(): boolean {
return this.invoice.items.length > 1;
}
}<!-- src/components/invoice-form.html -->
<form submit.trigger="submit()" class="invoice-form">
<h2>Create Invoice</h2>
<!-- Invoice Header -->
<div class="invoice-header">
<div class="form-row">
<div class="form-field">
<label for="invoiceNumber">Invoice Number *</label>
<input
type="text"
id="invoiceNumber"
value.bind="invoice.invoiceNumber & validate"
readonly>
</div>
<div class="form-field">
<label for="invoiceDate">Invoice Date *</label>
<input
type="date"
id="invoiceDate"
value.bind="invoice.invoiceDate & validate">
</div>
<div class="form-field">
<label for="dueDate">Due Date *</label>
<input
type="date"
id="dueDate"
value.bind="invoice.dueDate & validate">
</div>
</div>
<div class="form-row">
<div class="form-field">
<label for="customerName">Customer Name *</label>
<input
type="text"
id="customerName"
value.bind="invoice.customerName & validate">
</div>
<div class="form-field">
<label for="customerEmail">Customer Email *</label>
<input
type="email"
id="customerEmail"
value.bind="invoice.customerEmail & validate">
</div>
</div>
</div>
<!-- Line Items -->
<div class="invoice-items">
<div class="items-header">
<h3>Line Items</h3>
<button
type="button"
click.trigger="addLineItem()"
class="btn btn-secondary btn-small">
+ Add Item
</button>
</div>
<div class="items-table">
<div class="items-table-header">
<div class="col-description">Description</div>
<div class="col-quantity">Qty</div>
<div class="col-price">Unit Price</div>
<div class="col-total">Total</div>
<div class="col-actions">Actions</div>
</div>
<div
repeat.for="item of invoice.items"
class="line-item"
id.bind="item.id">
<div class="col-description">
<input
type="text"
value.bind="item.description"
change.trigger="updateLineItem(item)"
placeholder="Item description"
aria-label="Description for item ${$index + 1}">
</div>
<div class="col-quantity">
<input
type="number"
value.bind="item.quantity"
change.trigger="updateLineItem(item)"
min="1"
step="1"
aria-label="Quantity for item ${$index + 1}">
</div>
<div class="col-price">
<input
type="number"
value.bind="item.unitPrice"
change.trigger="updateLineItem(item)"
min="0"
step="0.01"
aria-label="Unit price for item ${$index + 1}">
</div>
<div class="col-total">
$${item.total | numberFormat:'0.00'}
</div>
<div class="col-actions">
<button
type="button"
click.trigger="duplicateLineItem(item.id)"
class="btn btn-icon"
title="Duplicate"
aria-label="Duplicate item ${$index + 1}">
📋
</button>
<button
type="button"
click.trigger="removeLineItem(item.id)"
disabled.bind="!canRemoveItem"
class="btn btn-icon btn-danger"
title="Remove"
aria-label="Remove item ${$index + 1}">
×
</button>
</div>
</div>
</div>
</div>
<!-- Totals -->
<div class="invoice-totals">
<div class="total-row">
<span>Subtotal:</span>
<span>$${invoice.subtotal | numberFormat:'0.00'}</span>
</div>
<div class="total-row">
<span>Tax (${taxRate * 100}%):</span>
<span>$${invoice.tax | numberFormat:'0.00'}</span>
</div>
<div class="total-row total-row-grand">
<span>Total:</span>
<span>$${invoice.total | numberFormat:'0.00'}</span>
</div>
</div>
<!-- Actions -->
<div class="form-actions">
<button type="submit" class="btn btn-primary">
Save Invoice
</button>
</div>
</form>Key Features:
Add/remove line items dynamically
Duplicate line items
Auto-calculate line totals and invoice totals
Validate entire array of items
Prevent removing last item
Unique IDs for each line item
Accessible labels for screen readers
Complex File Uploads with Preview & Progress
Handle multiple file uploads with image previews, progress tracking, and validation.
Complete Example: Image Gallery Upload
// src/components/image-upload.ts
import { newInstanceForScope } from '@aurelia/kernel';
import { IValidationRules } from '@aurelia/validation';
import { IValidationController } from '@aurelia/validation-html';
interface UploadedFile {
id: string;
file: File;
preview: string;
progress: number;
status: 'pending' | 'uploading' | 'complete' | 'error';
error?: string;
}
export class ImageUpload {
private files: UploadedFile[] = [];
private dragOver = false;
private nextId = 1;
private readonly maxFileSize = 5 * 1024 * 1024; // 5MB
private readonly maxFiles = 10;
private readonly allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
private validation = resolve(newInstanceForScope(IValidationController));
constructor(
@IValidationRules validationRules: IValidationRules = resolve(IValidationRules)
) {
validationRules
.on(this)
.ensure('files')
.required()
.minItems(1)
.withMessage('Please upload at least one image')
.satisfies((files: UploadedFile[]) => files.length <= this.maxFiles)
.withMessage(`Maximum ${this.maxFiles} images allowed`);
}
handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files || []);
this.addFiles(files);
// Clear input so same file can be selected again
input.value = '';
}
handleDrop(event: DragEvent) {
event.preventDefault();
this.dragOver = false;
const files = Array.from(event.dataTransfer?.files || []);
this.addFiles(files);
}
handleDragOver(event: DragEvent) {
event.preventDefault();
this.dragOver = true;
}
handleDragLeave() {
this.dragOver = false;
}
private addFiles(files: File[]) {
for (const file of files) {
// Check if we've reached the limit
if (this.files.length >= this.maxFiles) {
alert(`Maximum ${this.maxFiles} files allowed`);
break;
}
// Validate file type
if (!this.allowedTypes.includes(file.type)) {
alert(`${file.name}: Invalid file type. Only images allowed.`);
continue;
}
// Validate file size
if (file.size > this.maxFileSize) {
alert(`${file.name}: File too large. Maximum ${this.maxFileSize / 1024 / 1024}MB.`);
continue;
}
// Create uploaded file entry
const uploadedFile: UploadedFile = {
id: `file-${this.nextId++}`,
file,
preview: '',
progress: 0,
status: 'pending'
};
this.files.push(uploadedFile);
// Generate preview
this.generatePreview(uploadedFile);
}
}
private generatePreview(uploadedFile: UploadedFile) {
const reader = new FileReader();
reader.onload = (e) => {
uploadedFile.preview = e.target?.result as string;
};
reader.readAsDataURL(uploadedFile.file);
}
removeFile(id: string) {
const index = this.files.findIndex(f => f.id === id);
if (index !== -1) {
this.files.splice(index, 1);
}
}
async uploadFile(uploadedFile: UploadedFile) {
if (uploadedFile.status === 'uploading' || uploadedFile.status === 'complete') {
return;
}
uploadedFile.status = 'uploading';
uploadedFile.progress = 0;
try {
const formData = new FormData();
formData.append('file', uploadedFile.file);
// Simulate upload with progress
await this.simulateUpload(uploadedFile);
uploadedFile.status = 'complete';
uploadedFile.progress = 100;
} catch (error) {
uploadedFile.status = 'error';
uploadedFile.error = error.message || 'Upload failed';
}
}
private async simulateUpload(uploadedFile: UploadedFile): Promise<void> {
// In real implementation, use XMLHttpRequest or fetch with progress
return new Promise((resolve) => {
const duration = 2000; // 2 seconds
const interval = 100; // Update every 100ms
const increment = (interval / duration) * 100;
const timer = setInterval(() => {
uploadedFile.progress += increment;
if (uploadedFile.progress >= 100) {
clearInterval(timer);
uploadedFile.progress = 100;
resolve();
}
}, interval);
});
}
async uploadAll() {
const pending = this.files.filter(f => f.status === 'pending' || f.status === 'error');
for (const file of pending) {
await this.uploadFile(file);
}
}
async submit() {
const result = await this.validation.validate();
if (!result.valid) {
return;
}
// Upload any pending files
await this.uploadAll();
// Check if all uploaded successfully
const hasErrors = this.files.some(f => f.status === 'error');
if (hasErrors) {
alert('Some files failed to upload. Please try again.');
return;
}
console.log('All files uploaded successfully!', this.files);
}
get uploadedCount(): number {
return this.files.filter(f => f.status === 'complete').length;
}
get totalSize(): string {
const bytes = this.files.reduce((sum, f) => sum + f.file.size, 0);
const mb = bytes / 1024 / 1024;
return `${mb.toFixed(2)} MB`;
}
}<!-- src/components/image-upload.html -->
<div class="image-upload">
<h2>Upload Images</h2>
<!-- Drop Zone -->
<div
class="drop-zone ${dragOver ? 'drag-over' : ''}"
drop.trigger="handleDrop($event)"
dragover.trigger="handleDragOver($event)"
dragleave.trigger="handleDragLeave()">
<div class="drop-zone-content">
<p class="drop-zone-icon">📁</p>
<p class="drop-zone-text">Drag & drop images here</p>
<p class="drop-zone-or">or</p>
<label for="fileInput" class="btn btn-primary">
Choose Files
</label>
<input
type="file"
id="fileInput"
change.trigger="handleFileSelect($event)"
multiple
accept="${allowedTypes.join(',')}"
style="display: none;">
<p class="drop-zone-hint">
Maximum ${maxFiles} files, ${maxFileSize / 1024 / 1024}MB each
</p>
</div>
</div>
<!-- File List -->
<div if.bind="files.length > 0" class="file-list">
<div class="file-list-header">
<h3>Selected Files (${files.length}/${maxFiles})</h3>
<div class="file-list-stats">
<span>${uploadedCount} uploaded</span>
<span>${totalSize} total</span>
</div>
</div>
<div class="file-grid">
<div
repeat.for="file of files"
class="file-item file-item-${file.status}">
<!-- Preview -->
<div class="file-preview">
<img
if.bind="file.preview"
src.bind="file.preview"
alt="${file.file.name}">
<div if.bind="!file.preview" class="file-preview-loading">
Loading...
</div>
</div>
<!-- Info -->
<div class="file-info">
<div class="file-name" title.bind="file.file.name">
${file.file.name}
</div>
<div class="file-size">
${file.file.size / 1024 | numberFormat:'0.0'} KB
</div>
</div>
<!-- Progress -->
<div if.bind="file.status === 'uploading'" class="file-progress">
<div class="progress-bar">
<div
class="progress-fill"
style="width: ${file.progress}%"></div>
</div>
<div class="progress-text">${file.progress | numberFormat:'0'}%</div>
</div>
<!-- Status -->
<div class="file-status">
<span if.bind="file.status === 'pending'" class="status-badge status-pending">
Pending
</span>
<span if.bind="file.status === 'uploading'" class="status-badge status-uploading">
Uploading...
</span>
<span if.bind="file.status === 'complete'" class="status-badge status-complete">
✓ Complete
</span>
<span if.bind="file.status === 'error'" class="status-badge status-error">
✕ ${file.error}
</span>
</div>
<!-- Actions -->
<div class="file-actions">
<button
if.bind="file.status === 'pending' || file.status === 'error'"
type="button"
click.trigger="uploadFile(file)"
class="btn btn-icon btn-small">
↑
</button>
<button
type="button"
click.trigger="removeFile(file.id)"
class="btn btn-icon btn-small btn-danger">
×
</button>
</div>
</div>
</div>
<!-- Bulk Actions -->
<div class="file-list-actions">
<button
type="button"
click.trigger="uploadAll()"
class="btn btn-secondary">
Upload All
</button>
<button
type="button"
click.trigger="submit()"
class="btn btn-primary">
Complete Upload
</button>
</div>
</div>
</div>Key Features:
Drag & drop support
Image preview generation
Progress tracking for each file
File type and size validation
Multiple file selection
Individual or bulk upload
Error handling per file
Visual status indicators
Dependent Dropdowns (Cascading Selects)
Dropdowns where options depend on previous selections, like country → state → city.
Complete Example: Location Selector
// src/components/location-selector.ts
import { newInstanceForScope } from '@aurelia/kernel';
import { IValidationRules } from '@aurelia/validation';
import { IValidationController } from '@aurelia/validation-html';
interface Country {
code: string;
name: string;
}
interface State {
code: string;
name: string;
countryCode: string;
}
interface City {
id: string;
name: string;
stateCode: string;
}
interface LocationForm {
country: string;
state: string;
city: string;
address: string;
zipCode: string;
}
export class LocationSelector {
private form: LocationForm = {
country: '',
state: '',
city: '',
address: '',
zipCode: ''
};
// Mock data (in real app, load from API)
private allCountries: Country[] = [
{ code: 'US', name: 'United States' },
{ code: 'CA', name: 'Canada' },
{ code: 'MX', name: 'Mexico' }
];
private allStates: State[] = [
{ code: 'CA', name: 'California', countryCode: 'US' },
{ code: 'NY', name: 'New York', countryCode: 'US' },
{ code: 'TX', name: 'Texas', countryCode: 'US' },
{ code: 'ON', name: 'Ontario', countryCode: 'CA' },
{ code: 'BC', name: 'British Columbia', countryCode: 'CA' },
{ code: 'JA', name: 'Jalisco', countryCode: 'MX' }
];
private allCities: City[] = [
{ id: '1', name: 'Los Angeles', stateCode: 'CA' },
{ id: '2', name: 'San Francisco', stateCode: 'CA' },
{ id: '3', name: 'New York City', stateCode: 'NY' },
{ id: '4', name: 'Buffalo', stateCode: 'NY' },
{ id: '5', name: 'Houston', stateCode: 'TX' },
{ id: '6', name: 'Dallas', stateCode: 'TX' },
{ id: '7', name: 'Toronto', stateCode: 'ON' },
{ id: '8', name: 'Vancouver', stateCode: 'BC' },
{ id: '9', name: 'Guadalajara', stateCode: 'JA' }
];
private isLoadingStates = false;
private isLoadingCities = false;
private validation = resolve(newInstanceForScope(IValidationController));
constructor(
@IValidationRules validationRules: IValidationRules = resolve(IValidationRules)
) {
validationRules
.on(this.form)
.ensure('country')
.required()
.ensure('state')
.required()
.ensure('city')
.required()
.ensure('address')
.required()
.minLength(5)
.ensure('zipCode')
.required()
.matches(/^\d{5}(-\d{4})?$/)
.withMessage('Please enter a valid ZIP code');
}
// Computed: Available states based on selected country
get availableStates(): State[] {
if (!this.form.country) return [];
return this.allStates.filter(s => s.countryCode === this.form.country);
}
// Computed: Available cities based on selected state
get availableCities(): City[] {
if (!this.form.state) return [];
return this.allCities.filter(c => c.stateCode === this.form.state);
}
// When country changes, reset dependent fields
async countryChanged(newValue: string, oldValue: string) {
if (newValue !== oldValue) {
this.form.state = '';
this.form.city = '';
// In real app, load states from API
if (newValue) {
this.isLoadingStates = true;
await this.loadStates(newValue);
this.isLoadingStates = false;
}
}
}
// When state changes, reset city
async stateChanged(newValue: string, oldValue: string) {
if (newValue !== oldValue) {
this.form.city = '';
// In real app, load cities from API
if (newValue) {
this.isLoadingCities = true;
await this.loadCities(newValue);
this.isLoadingCities = false;
}
}
}
private async loadStates(countryCode: string): Promise<void> {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));
// States are already filtered by computed property
}
private async loadCities(stateCode: string): Promise<void> {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));
// Cities are already filtered by computed property
}
async submit() {
const result = await this.validation.validate();
if (!result.valid) {
return;
}
console.log('Location submitted:', this.form);
}
}<!-- src/components/location-selector.html -->
<form submit.trigger="submit()" class="location-form">
<h2>Enter Your Location</h2>
<div class="form-field">
<label for="country">Country *</label>
<select
id="country"
value.bind="form.country & validate">
<option value="">Select a country</option>
<option
repeat.for="country of allCountries"
value.bind="country.code">
${country.name}
</option>
</select>
</div>
<div class="form-field">
<label for="state">State/Province *</label>
<select
id="state"
value.bind="form.state & validate"
disabled.bind="!form.country || isLoadingStates">
<option value="">
${isLoadingStates ? 'Loading...' : 'Select a state'}
</option>
<option
repeat.for="state of availableStates"
value.bind="state.code">
${state.name}
</option>
</select>
<div if.bind="!form.country" class="field-hint">
Please select a country first
</div>
</div>
<div class="form-field">
<label for="city">City *</label>
<select
id="city"
value.bind="form.city & validate"
disabled.bind="!form.state || isLoadingCities">
<option value="">
${isLoadingCities ? 'Loading...' : 'Select a city'}
</option>
<option
repeat.for="city of availableCities"
value.bind="city.id">
${city.name}
</option>
</select>
<div if.bind="!form.state" class="field-hint">
Please select a state first
</div>
</div>
<div class="form-field">
<label for="address">Street Address *</label>
<input
type="text"
id="address"
value.bind="form.address & validate"
placeholder="123 Main St">
</div>
<div class="form-field">
<label for="zipCode">ZIP/Postal Code *</label>
<input
type="text"
id="zipCode"
value.bind="form.zipCode & validate"
placeholder="12345">
</div>
<button type="submit" class="btn btn-primary">
Continue
</button>
</form>Key Features:
Cascading selects (country → state → city)
Computed properties for filtered options
Auto-reset dependent fields when parent changes
Loading states while fetching options
Disabled state until parent is selected
Helpful hints for users
Reusable Form Field Components
Create reusable form field components that encapsulate label, input, validation, and error display.
Complete Example: Validated Text Field Component
// src/components/validated-field.ts
import { bindable, INode } from '@aurelia/runtime-html';
import { resolve } from '@aurelia/kernel';
import { IValidationController } from '@aurelia/validation-html';
export class ValidatedField {
@bindable label: string;
@bindable value: any;
@bindable type: string = 'text';
@bindable placeholder: string = '';
@bindable required: boolean = false;
@bindable disabled: boolean = false;
@bindable hint: string = '';
@bindable validation: IValidationController;
private fieldId: string;
private inputElement: HTMLInputElement;
private element = resolve(INode);
constructor() {
this.fieldId = `field-${Math.random().toString(36).substr(2, 9)}`;
}
get errors(): string[] {
if (!this.validation) return [];
const results = this.validation.results || [];
return results
.filter(r => !r.valid && r.propertyName === this.getPropertyName())
.map(r => r.message);
}
get hasError(): boolean {
return this.errors.length > 0;
}
private getPropertyName(): string {
// Extract property name from binding expression
// This is a simplified version
const binding = this.element.getAttribute('value.bind');
return binding?.split('&')[0].trim() || '';
}
focus() {
this.inputElement?.focus();
}
}<!-- src/components/validated-field.html -->
<div class="form-field ${hasError ? 'has-error' : ''}">
<label for.bind="fieldId">
${label}
<span if.bind="required" class="required-indicator">*</span>
</label>
<input
ref="inputElement"
type.bind="type"
id.bind="fieldId"
value.bind="value & validate"
placeholder.bind="placeholder"
disabled.bind="disabled"
aria-invalid.bind="hasError"
aria-describedby="${fieldId}-hint ${hasError ? `${fieldId}-error` : ''}">
<div
if.bind="hint && !hasError"
id="${fieldId}-hint"
class="field-hint">
${hint}
</div>
<div
if.bind="hasError"
id="${fieldId}-error"
class="field-error"
role="alert">
${errors[0]}
</div>
</div>Usage Example
// src/pages/signup.ts
import { newInstanceForScope } from '@aurelia/kernel';
import { IValidationRules } from '@aurelia/validation';
import { IValidationController } from '@aurelia/validation-html';
export class Signup {
private user = {
username: '',
email: '',
password: '',
confirmPassword: ''
};
private validation = resolve(newInstanceForScope(IValidationController));
constructor(
@IValidationRules validationRules: IValidationRules = resolve(IValidationRules)
) {
validationRules
.on(this.user)
.ensure('username')
.required()
.minLength(3)
.matches(/^[a-zA-Z0-9_]+$/)
.withMessage('Username can only contain letters, numbers, and underscores')
.ensure('email')
.required()
.email()
.ensure('password')
.required()
.minLength(8)
.ensure('confirmPassword')
.required()
.satisfies((value: string) => value === this.user.password)
.withMessage('Passwords must match');
}
async submit() {
const result = await this.validation.validate();
if (!result.valid) return;
console.log('Signup:', this.user);
}
}<!-- src/pages/signup.html -->
<form submit.trigger="submit()">
<h2>Sign Up</h2>
<validated-field
label="Username"
value.bind="user.username"
validation.bind="validation"
required.bind="true"
hint="Letters, numbers, and underscores only">
</validated-field>
<validated-field
label="Email"
type="email"
value.bind="user.email"
validation.bind="validation"
required.bind="true">
</validated-field>
<validated-field
label="Password"
type="password"
value.bind="user.password"
validation.bind="validation"
required.bind="true"
hint="Minimum 8 characters">
</validated-field>
<validated-field
label="Confirm Password"
type="password"
value.bind="user.confirmPassword"
validation.bind="validation"
required.bind="true">
</validated-field>
<button type="submit" class="btn btn-primary">
Create Account
</button>
</form>Key Features:
Encapsulates label, input, validation, error display
Reusable across entire application
Consistent styling and behavior
Accessible (proper ARIA attributes)
Reduced boilerplate in forms
Related Documentation
Form Basics - Basic form concepts
Validation Plugin - Complete validation guide
Form Submission - Handling form submission
Collections (Checkboxes, Radios, Select) - Working with form collections
File Uploads - File upload patterns
Summary
These advanced patterns handle complex real-world scenarios:
Multi-step wizards - Break complex forms into manageable steps with conditional validation and progress tracking
Dynamic forms - Add/remove individual fields at runtime with validation
Conditional validation - Validation rules that depend on other field values
Form state management - Track changes, prevent data loss, implement autosave with router guards
Form arrays - Repeating field groups (like invoice line items) with add/remove/duplicate functionality
Complex file uploads - Multiple file uploads with drag & drop, previews, progress tracking, and per-file validation
Dependent dropdowns - Cascading selects (country → state → city) with auto-reset and loading states
Reusable form fields - Encapsulated field components with built-in validation display
All examples use proper Aurelia 2 syntax with the validation plugin and follow accessibility best practices. Each pattern includes complete, production-ready code with TypeScript interfaces, validation rules, and accessible HTML templates.
Last updated
Was this helpful?