Form Inputs
Master Aurelia 2 forms with comprehensive coverage of binding patterns, advanced collections, validation integration, and performance optimization for production applications.
Forms are the cornerstone of interactive web applications. Whether you're building simple contact forms, complex data-entry systems, or dynamic configuration interfaces, Aurelia provides a comprehensive and performant forms system. This guide covers everything from basic input binding to advanced patterns like collection-based form controls, dynamic form generation, and seamless validation integration.
Table of Contents
Understanding Aurelia's Form Architecture
Aurelia's forms system is built on sophisticated observer patterns that provide automatic synchronization between your view models and form controls. Understanding this architecture helps you build more efficient and maintainable forms.
Data Flow Architecture
User Input → DOM Event → Observer → Binding → View Model → Reactive Updates
↑ ↓
Form Element ← DOM Update ← Binding ← Property Change ← View Model
Key Components:
Observers: Monitor DOM events and property changes
Bindings: Connect observers to view model properties
Collection Observers: Handle arrays, Sets, and Maps efficiently
Mutation Observers: Track dynamic DOM changes
Value Converters & Binding Behaviors: Transform and control data flow
Automatic Change Detection
Aurelia automatically observes:
Text inputs:
input
,change
,keyup
eventsCheckboxes/Radio:
change
events with array synchronizationSelect elements:
change
events with mutation observationCollections: Array mutations, Set/Map changes
Object properties: Deep property observation
This means you typically don't need manual event handlers—Aurelia handles the complexity automatically while providing hooks for customization when needed.
Basic Input Binding
Aurelia provides intuitive two-way binding for all standard form elements. Let's start with the fundamentals and build toward advanced patterns.
Simple Text Inputs
The foundation of most forms is text input binding:
<form submit.trigger="handleSubmit()">
<div class="form-group">
<label for="email">Email:</label>
<input id="email"
type="email"
value.bind="email"
placeholder.bind="emailPlaceholder" />
</div>
<div class="form-group">
<label for="password">Password:</label>
<input id="password"
type="password"
value.bind="password" />
</div>
<button type="submit" disabled.bind="!isFormValid">Login</button>
</form>
export class LoginComponent {
email = '';
password = '';
emailPlaceholder = 'Enter your email address';
get isFormValid(): boolean {
return this.email.length > 0 && this.password.length >= 8;
}
handleSubmit() {
if (this.isFormValid) {
// Process form submission
console.log('Submitting:', { email: this.email, password: this.password });
}
}
}
Textarea Binding
Textareas work identically to text inputs:
<div class="form-group">
<label for="comments">Comments:</label>
<textarea id="comments"
value.bind="comments"
rows="4"
maxlength.bind="maxCommentLength"></textarea>
<small>${comments.length}/${maxCommentLength} characters</small>
</div>
export class FeedbackForm {
comments = '';
maxCommentLength = 500;
}
Number and Date Inputs
For specialized input types, Aurelia handles type coercion automatically:
<div class="form-group">
<label for="age">Age:</label>
<input id="age"
type="number"
value.bind="age"
min="18"
max="120" />
</div>
<div class="form-group">
<label for="birthdate">Birth Date:</label>
<input id="birthdate"
type="date"
value.bind="birthDate" />
</div>
export class ProfileForm {
age: number = 25;
birthDate: Date = new Date('1998-01-01');
// Computed property demonstrating reactive updates
get isAdult(): boolean {
return this.age >= 18;
}
}
Binding With Text and Textarea Inputs
Text Input
Binding to text inputs in Aurelia is straightforward:
<form>
<label>User value:</label><br />
<input type="text" value.bind="userValue" />
</form>
You can also bind other attributes like placeholder:
<form>
<label>User value:</label><br />
<input type="text" value.bind="userValue" placeholder.bind="myPlaceholder" />
</form>
Textarea
Textareas work just like text inputs, with value.bind for two-way binding:
<form>
<label>Comments:</label><br />
<textarea value.bind="textAreaValue"></textarea>
</form>
Any changes to textAreaValue
in the view model will show up in the <textarea>
, and vice versa.
Advanced Collection Patterns
One of Aurelia's most powerful features is its sophisticated support for collection-based form controls. Beyond simple arrays, Aurelia supports Sets, Maps, and custom collection types with optimal performance characteristics.
Boolean Checkboxes
The simplest checkbox pattern binds to boolean properties:
export class PreferencesForm {
emailNotifications = false;
smsNotifications = true;
pushNotifications = false;
// Computed property for form validation
get hasValidNotificationPrefs(): boolean {
return this.emailNotifications || this.smsNotifications || this.pushNotifications;
}
}
<form>
<fieldset>
<legend>Notification Preferences</legend>
<label>
<input type="checkbox" checked.bind="emailNotifications" />
Email notifications
</label>
<label>
<input type="checkbox" checked.bind="smsNotifications" />
SMS notifications
</label>
<label>
<input type="checkbox" checked.bind="pushNotifications" />
Push notifications
</label>
</fieldset>
<div if.bind="!hasValidNotificationPrefs" class="warning">
Please select at least one notification method.
</div>
</form>
Array-Based Multi-Select
For traditional multi-select scenarios, bind arrays to checkbox groups:
interface Product {
id: number;
name: string;
category: string;
price: number;
}
export class ProductSelectionForm {
products: Product[] = [
{ id: 1, name: "Gaming Mouse", category: "Peripherals", price: 89.99 },
{ id: 2, name: "Mechanical Keyboard", category: "Peripherals", price: 159.99 },
{ id: 3, name: "4K Monitor", category: "Display", price: 399.99 },
{ id: 4, name: "Graphics Card", category: "Components", price: 599.99 }
];
// Array of selected product IDs
selectedProductIds: number[] = [];
// Array of selected product objects
selectedProducts: Product[] = [];
get totalValue(): number {
return this.selectedProducts.reduce((sum, product) => sum + product.price, 0);
}
}
<form>
<h3>Select Products</h3>
<!-- ID-based selection -->
<div class="product-grid">
<div repeat.for="product of products" class="product-card">
<label>
<input type="checkbox"
model.bind="product.id"
checked.bind="selectedProductIds" />
<strong>${product.name}</strong>
<span class="category">${product.category}</span>
<span class="price">$${product.price}</span>
</label>
</div>
</div>
<!-- Object-based selection (more flexible) -->
<h4>Or select complete product objects:</h4>
<div class="product-list">
<label repeat.for="product of products" class="product-item">
<input type="checkbox"
model.bind="product"
checked.bind="selectedProducts" />
${product.name} - $${product.price}
</label>
</div>
<div class="summary" if.bind="selectedProducts.length">
<h4>Selected Items (${selectedProducts.length})</h4>
<ul>
<li repeat.for="product of selectedProducts">
${product.name} - $${product.price}
</li>
</ul>
<strong>Total: $${totalValue | number:'0.00'}</strong>
</div>
</form>
Set-Based Collections (Advanced)
For high-performance scenarios with frequent additions/removals, use Set collections:
export class TagSelectionForm {
availableTags = [
{ id: 'frontend', name: 'Frontend Development', color: '#blue' },
{ id: 'backend', name: 'Backend Development', color: '#green' },
{ id: 'database', name: 'Database Design', color: '#orange' },
{ id: 'devops', name: 'DevOps', color: '#purple' },
{ id: 'mobile', name: 'Mobile Development', color: '#red' }
];
// Set-based selection for O(1) lookups
selectedTags: Set<string> = new Set(['frontend', 'database']);
// Custom matcher for Set operations
tagMatcher = (a: any, b: any) => {
if (typeof a === 'string' && typeof b === 'object') return a === b.id;
if (typeof b === 'string' && typeof a === 'object') return b === a.id;
return a === b;
};
get selectedTagList() {
return this.availableTags.filter(tag => this.selectedTags.has(tag.id));
}
toggleTag(tagId: string) {
if (this.selectedTags.has(tagId)) {
this.selectedTags.delete(tagId);
} else {
this.selectedTags.add(tagId);
}
}
}
<form>
<h3>Select Your Skills</h3>
<div class="tag-container">
<label repeat.for="tag of availableTags"
class="tag-label"
css.bind="{ '--tag-color': tag.color }">
<input type="checkbox"
model.bind="tag.id"
checked.bind="selectedTags"
matcher.bind="tagMatcher" />
<span class="tag-text">${tag.name}</span>
</label>
</div>
<div if.bind="selectedTags.size > 0" class="selected-tags">
<h4>Selected Skills (${selectedTags.size})</h4>
<div class="tag-chips">
<span repeat.for="tag of selectedTagList" class="tag-chip">
${tag.name}
<button type="button"
click.trigger="toggleTag(tag.id)"
class="remove-tag">×</button>
</span>
</div>
</div>
</form>
Map-Based Collections (Expert Level)
For complex key-value selections, Maps provide the most flexibility:
interface Permission {
resource: string;
actions: string[];
description: string;
}
export class PermissionForm {
permissions: Permission[] = [
{
resource: 'users',
actions: ['create', 'read', 'update', 'delete'],
description: 'User management operations'
},
{
resource: 'posts',
actions: ['create', 'read', 'update', 'delete', 'publish'],
description: 'Content management operations'
},
{
resource: 'settings',
actions: ['read', 'update'],
description: 'System configuration'
}
];
// Map: resource -> Set<action>
selectedPermissions: Map<string, Set<string>> = new Map();
constructor() {
// Initialize with default permissions
this.selectedPermissions.set('users', new Set(['read']));
this.selectedPermissions.set('posts', new Set(['read', 'create']));
}
hasPermission(resource: string, action: string): boolean {
return this.selectedPermissions.get(resource)?.has(action) ?? false;
}
togglePermission(resource: string, action: string) {
if (!this.selectedPermissions.has(resource)) {
this.selectedPermissions.set(resource, new Set());
}
const resourcePerms = this.selectedPermissions.get(resource)!;
if (resourcePerms.has(action)) {
resourcePerms.delete(action);
} else {
resourcePerms.add(action);
}
}
get permissionSummary() {
const summary: Array<{ resource: string; actions: string[] }> = [];
this.selectedPermissions.forEach((actions, resource) => {
if (actions.size > 0) {
summary.push({ resource, actions: Array.from(actions) });
}
});
return summary;
}
}
<form>
<h3>Configure Permissions</h3>
<div class="permission-matrix">
<div repeat.for="permission of permissions" class="permission-group">
<h4>${permission.resource | capitalize}</h4>
<p class="description">${permission.description}</p>
<div class="action-checkboxes">
<label repeat.for="action of permission.actions" class="action-label">
<input type="checkbox"
checked.bind="hasPermission(permission.resource, action)"
change.trigger="togglePermission(permission.resource, action)" />
${action | capitalize}
</label>
</div>
</div>
</div>
<div if.bind="permissionSummary.length > 0" class="permission-summary">
<h4>Selected Permissions</h4>
<ul>
<li repeat.for="perm of permissionSummary">
<strong>${perm.resource}</strong>: ${perm.actions.join(', ')}
</li>
</ul>
</div>
</form>
Performance Considerations
Choose the right collection type for your use case:
Arrays: General purpose, good for small to medium collections
Sets: High-performance for frequent additions/removals, O(1) lookups
Maps: Complex key-value relationships, nested selections
Custom Matchers: When object identity comparison isn't sufficient
Performance Tips:
Use Set for large collections with frequent changes
Implement efficient matcher functions for object comparison
Avoid creating new objects in templates—use computed properties
Consider virtualization for very large checkbox lists
Event Handling and Binding Behaviors
Aurelia provides sophisticated event handling and binding behaviors that give you precise control over when and how form data synchronizes. These features are crucial for building responsive, performant forms.
Advanced Event Timing with updateTrigger
By default, Aurelia uses appropriate events for each input type, but you can customize this behavior:
export class AdvancedForm {
searchQuery = '';
username = '';
description = '';
// Debounced search handler
performSearch = debounce((query: string) => {
console.log('Searching for:', query);
// Perform API call
}, 300);
searchQueryChanged(newValue: string) {
this.performSearch(newValue);
}
}
<form>
<!-- Update on every keystroke (input event) -->
<input type="text"
value.bind="searchQuery & updateTrigger:'input'"
placeholder="Real-time search..." />
<!-- Update on focus loss (blur event) -->
<input type="text"
value.bind="username & updateTrigger:'blur'"
placeholder="Username (validates on blur)" />
<!-- Multiple events -->
<textarea value.bind="description & updateTrigger:['input', 'blur']"
placeholder="Auto-save draft on input and blur"></textarea>
<!-- Custom events -->
<input type="text"
value.bind="customValue & updateTrigger:'keydown':'focus'"
placeholder="Updates on keydown and focus" />
</form>
Rate Limiting with Debounce and Throttle
Control the frequency of updates to improve performance and user experience:
export class SearchForm {
searchTerm = '';
scrollPosition = 0;
apiCallCount = 0;
// This will be called max once per 300ms
searchTermChanged(newTerm: string) {
this.apiCallCount++;
console.log(`API Call #${this.apiCallCount}: Searching for "${newTerm}"`);
}
// Throttled scroll tracking
onScroll(position: number) {
console.log('Scroll position:', position);
}
}
<form>
<!-- Debounce: Wait for pause in typing -->
<input type="search"
value.bind="searchTerm & debounce:300"
placeholder="Search with 300ms debounce..." />
<!-- Throttle: Maximum rate limiting -->
<input type="range"
min="0" max="100"
value.bind="scrollPosition & throttle:100"
input.trigger="onScroll(scrollPosition)" />
<!-- Signal-based cache invalidation -->
<input type="text"
value.bind="searchTerm & debounce:300:'searchSignal'"
placeholder="Cache-aware search" />
</form>
Signal-Based Reactive Updates
Signals provide cache invalidation and coordinated updates across components:
import { resolve } from '@aurelia/kernel';
import { observable, computedFrom } from '@aurelia/runtime';
import { ISignaler } from '@aurelia/runtime-html';
export class SignalDrivenForm {
@observable searchCriteria = {
term: '',
category: 'all',
priceRange: [0, 1000]
};
// Signal dispatcher
private signaler = resolve(ISignaler);
updateSearch(criteria: Partial<typeof this.searchCriteria>) {
Object.assign(this.searchCriteria, criteria);
// Notify all signal listeners
this.signaler.dispatchSignal('searchUpdated');
}
// Expensive computed property with signal-based cache
@computedFrom('searchCriteria', 'searchUpdated')
get searchResults() {
// This will only recompute when 'searchUpdated' signal is dispatched
return this.performExpensiveSearch(this.searchCriteria);
}
private performExpensiveSearch(criteria: any) {
console.log('Performing expensive search operation...');
// Simulate expensive computation
return [];
}
}
<form>
<!-- Signal-coordinated form fields -->
<input type="search"
value.bind="searchCriteria.term & debounce:300:'searchUpdated'" />
<select value.bind="searchCriteria.category & signal:'searchUpdated'">
<option value="all">All Categories</option>
<option value="electronics">Electronics</option>
<option value="books">Books</option>
</select>
<input type="range"
min="0" max="1000"
value.bind="searchCriteria.priceRange[1] & throttle:200:'searchUpdated'" />
<!-- Results update automatically via signal -->
<div class="results">
<p>Found ${searchResults.length} results</p>
<!-- Results rendered here -->
</div>
</form>
Dynamic Forms and Performance
Building performant, dynamic forms requires understanding Aurelia's observation system and applying optimization strategies for complex scenarios.
Dynamic Field Generation
Create forms that adapt their structure based on configuration:
import { resolve, newInstanceForScope } from '@aurelia/kernel';
import { IValidationRules } from '@aurelia/validation';
import { IValidationController } from '@aurelia/validation-html';
interface FieldConfig {
type: 'text' | 'number' | 'select' | 'checkbox' | 'textarea';
name: string;
label: string;
required?: boolean;
options?: Array<{ value: any; label: string }>;
validation?: string[];
placeholder?: string;
min?: number;
max?: number;
}
export class DynamicFormGenerator {
formConfig: FieldConfig[] = [];
formData: Record<string, any> = {};
formSchema: string = '';
private readonly validationRules = resolve(IValidationRules);
private readonly validationController = resolve(newInstanceForScope(IValidationController));
// Load form configuration from various sources
async loadFormConfiguration(schemaId: string) {
try {
const response = await fetch(`/api/forms/schema/${schemaId}`);
this.formConfig = await response.json();
this.initializeFormData();
} catch (error) {
console.error('Failed to load form schema:', error);
}
}
private initializeFormData() {
this.formData = {};
this.formConfig.forEach(field => {
switch (field.type) {
case 'checkbox':
this.formData[field.name] = false;
break;
case 'number':
this.formData[field.name] = field.min || 0;
break;
default:
this.formData[field.name] = '';
}
});
}
// Dynamic validation rule setup
setupDynamicValidation() {
this.formConfig.forEach(fieldConfig => {
let rule = this.validationRules.on(this.formData).ensure(fieldConfig.name);
if (fieldConfig.required) {
rule = rule.required().withMessage(`${fieldConfig.label} is required`);
}
if (fieldConfig.validation) {
fieldConfig.validation.forEach(validationType => {
switch (validationType) {
case 'email':
rule = rule.email().withMessage('Please enter a valid email address');
break;
case 'min-length-5':
rule = rule.minLength(5).withMessage(`${fieldConfig.label} must be at least 5 characters`);
break;
// Add more validation types as needed
}
});
}
if (fieldConfig.type === 'number') {
if (fieldConfig.min !== undefined) {
rule = rule.min(fieldConfig.min);
}
if (fieldConfig.max !== undefined) {
rule = rule.max(fieldConfig.max);
}
}
});
}
addField(fieldConfig: FieldConfig) {
this.formConfig.push(fieldConfig);
// Initialize form data for new field
this.formData[fieldConfig.name] = fieldConfig.type === 'checkbox' ? false : '';
this.setupDynamicValidation();
}
removeField(fieldName: string) {
this.formConfig = this.formConfig.filter(field => field.name !== fieldName);
delete this.formData[fieldName];
}
async submitDynamicForm() {
const validationResult = await this.validationController.validate();
if (validationResult.valid) {
const payload = {
schemaId: this.formSchema,
data: this.formData,
timestamp: new Date().toISOString()
};
try {
const response = await fetch('/api/forms/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (response.ok) {
console.log('Form submitted successfully');
}
} catch (error) {
console.error('Form submission failed:', error);
}
}
}
}
<form submit.trigger="submitDynamicForm()" class="dynamic-form">
<h2>Dynamic Form Generator</h2>
<div class="form-controls">
<label for="schema-select">Form Schema:</label>
<select id="schema-select"
value.bind="formSchema"
change.trigger="loadFormConfiguration(formSchema)">
<option value="">Select a form schema...</option>
<option value="contact">Contact Form</option>
<option value="survey">Survey Form</option>
<option value="registration">Registration Form</option>
</select>
</div>
<div if.bind="formConfig.length > 0" class="dynamic-fields">
<div repeat.for="field of formConfig" class="form-group">
<!-- Text Input -->
<div if.bind="field.type === 'text'" class="field-container">
<label for.bind="field.name">${field.label}</label>
<input type="text"
id.bind="field.name"
value.bind="formData[field.name] & validate"
placeholder.bind="field.placeholder"
class="form-control" />
</div>
<!-- Number Input -->
<div if.bind="field.type === 'number'" class="field-container">
<label for.bind="field.name">${field.label}</label>
<input type="number"
id.bind="field.name"
value.bind="formData[field.name] & validate"
min.bind="field.min"
max.bind="field.max"
class="form-control" />
</div>
<!-- Select Dropdown -->
<div if.bind="field.type === 'select'" class="field-container">
<label for.bind="field.name">${field.label}</label>
<select id.bind="field.name"
value.bind="formData[field.name] & validate"
class="form-control">
<option value="">Choose...</option>
<option repeat.for="option of field.options"
model.bind="option.value">
${option.label}
</option>
</select>
</div>
<!-- Checkbox -->
<div if.bind="field.type === 'checkbox'" class="field-container">
<label class="checkbox-label">
<input type="checkbox"
checked.bind="formData[field.name] & validate" />
${field.label}
</label>
</div>
<!-- Textarea -->
<div if.bind="field.type === 'textarea'" class="field-container">
<label for.bind="field.name">${field.label}</label>
<textarea id.bind="field.name"
value.bind="formData[field.name] & validate"
placeholder.bind="field.placeholder"
rows="4"
class="form-control"></textarea>
</div>
<!-- Field Management (Development Mode) -->
<div class="field-actions" if.bind="developmentMode">
<button type="button"
click.trigger="removeField(field.name)"
class="btn btn-sm btn-danger">
Remove Field
</button>
</div>
</div>
</div>
<div class="form-actions" if.bind="formConfig.length > 0">
<button type="submit" class="btn btn-primary">Submit Form</button>
<button type="button"
click.trigger="formData = {}"
class="btn btn-secondary">Clear Form</button>
</div>
<!-- Debug Information -->
<div class="debug-panel" if.bind="debugMode">
<h4>Form Data Debug</h4>
<pre>${formData | json}</pre>
<h4>Form Configuration Debug</h4>
<pre>${formConfig | json}</pre>
</div>
</form>
Performance Optimization Strategies
Implement performance optimizations for large, complex forms:
export class PerformantFormComponent {
// Virtual scrolling for large option lists
largeDataSet: any[] = [];
virtualScrollOptions = {
itemHeight: 40,
containerHeight: 300,
buffer: 10
};
// Lazy loading of form sections
private loadedSections: Set<string> = new Set();
// Debounced validation for expensive operations
private debouncedValidations = new Map<string, Function>();
// Efficient collection operations
selectedItems: Set<any> = new Set();
// Memoized computed properties
@computedFrom('formData.firstName', 'formData.lastName', 'formData.email')
get computedSummary() {
// Expensive computation that only runs when dependencies change
return this.generateFormSummary();
}
// Optimize large collection updates
updateLargeCollection(newItems: any[]) {
// Use Set for O(1) lookups instead of Array.includes()
const newItemsSet = new Set(newItems.map(item => item.id));
// Batch updates to minimize observer notifications
this.startBatch();
this.largeDataSet = this.largeDataSet.filter(item => {
if (newItemsSet.has(item.id)) {
// Update existing item efficiently
Object.assign(item, newItems.find(newItem => newItem.id === item.id));
return true;
}
return false;
});
// Add new items
newItems.forEach(item => {
if (!this.largeDataSet.find(existing => existing.id === item.id)) {
this.largeDataSet.push(item);
}
});
this.endBatch();
}
// Efficient form section loading
async loadFormSection(sectionName: string) {
if (this.loadedSections.has(sectionName)) {
return; // Already loaded
}
try {
const sectionData = await fetch(`/api/forms/sections/${sectionName}`);
const section = await sectionData.json();
// Load section-specific validation rules
this.loadSectionValidation(section);
this.loadedSections.add(sectionName);
} catch (error) {
console.error(`Failed to load section ${sectionName}:`, error);
}
}
// Memory-efficient validation
createEfficientValidator(fieldName: string, validatorFn: Function, delay: number = 300) {
if (this.debouncedValidations.has(fieldName)) {
// Cleanup existing validator
const existingValidator = this.debouncedValidations.get(fieldName);
existingValidator.cancel?.();
}
const debouncedValidator = debounce(validatorFn, delay);
this.debouncedValidations.set(fieldName, debouncedValidator);
return debouncedValidator;
}
// Cleanup on disposal
dispose() {
// Cancel all pending validations
this.debouncedValidations.forEach(validator => {
validator.cancel?.();
});
this.debouncedValidations.clear();
}
private startBatch() {
// Implementation depends on your observer system
// This is a conceptual example
}
private endBatch() {
// Implementation depends on your observer system
// This is a conceptual example
}
private generateFormSummary() {
// Expensive computation
return {
completionPercentage: this.calculateCompletionPercentage(),
validationStatus: this.getOverallValidationStatus(),
estimatedTimeToComplete: this.estimateTimeToComplete()
};
}
private calculateCompletionPercentage(): number {
// Calculate based on filled vs empty fields
return 85; // Example value
}
private getOverallValidationStatus(): string {
// Aggregate validation status
return 'partial'; // Example value
}
private estimateTimeToComplete(): number {
// Estimate in minutes
return 5; // Example value
}
private loadSectionValidation(section: any) {
// Load validation rules specific to the section
}
}
Radio Button and Select Element Patterns
Aurelia provides comprehensive support for single-selection controls with sophisticated object binding and custom matching logic.
Advanced Radio Button Groups
Radio buttons with complex object handling and conditional logic:
interface PaymentMethod {
id: string;
type: 'credit' | 'debit' | 'paypal' | 'crypto';
name: string;
fee: number;
processingTime: string;
requiresVerification: boolean;
}
export class PaymentSelectionForm {
paymentMethods: PaymentMethod[] = [
{
id: 'cc-visa',
type: 'credit',
name: 'Visa Credit Card',
fee: 0,
processingTime: 'Instant',
requiresVerification: false
},
{
id: 'pp-account',
type: 'paypal',
name: 'PayPal Account',
fee: 2.50,
processingTime: '1-2 business days',
requiresVerification: true
},
{
id: 'btc-wallet',
type: 'crypto',
name: 'Bitcoin Wallet',
fee: 0.0001,
processingTime: '10-60 minutes',
requiresVerification: true
}
];
selectedPaymentMethod: PaymentMethod | null = null;
// Custom matcher for complex object comparison
paymentMethodMatcher = (a: PaymentMethod, b: PaymentMethod) => {
return a?.id === b?.id;
};
// Computed properties based on selection
get totalFee(): number {
return this.selectedPaymentMethod?.fee || 0;
}
get requiresUserVerification(): boolean {
return this.selectedPaymentMethod?.requiresVerification || false;
}
get processingDetails(): string {
if (!this.selectedPaymentMethod) return '';
return `Processing time: ${this.selectedPaymentMethod.processingTime}
${this.totalFee > 0 ? `| Fee: $${this.totalFee.toFixed(2)}` : '| No additional fees'}`;
}
// Conditional validation based on selection
validatePaymentSelection(): boolean {
if (!this.selectedPaymentMethod) return false;
if (this.requiresUserVerification && !this.isUserVerified()) {
console.warn('User verification required for selected payment method');
return false;
}
return true;
}
private isUserVerified(): boolean {
// Check user verification status
return false; // Placeholder
}
}
<form class="payment-selection-form">
<h3>Select Payment Method</h3>
<div class="payment-options">
<div repeat.for="method of paymentMethods" class="payment-option">
<label class="payment-card"
class.bind="{ 'selected': selectedPaymentMethod?.id === method.id }">
<input type="radio"
name="paymentMethod"
model.bind="method"
checked.bind="selectedPaymentMethod"
matcher.bind="paymentMethodMatcher"
class="payment-radio" />
<div class="payment-info">
<div class="payment-header">
<span class="payment-name">${method.name}</span>
<span class="payment-type badge"
class.bind="method.type">${method.type}</span>
</div>
<div class="payment-details">
<div class="processing-time">
<i class="icon-clock"></i>
${method.processingTime}
</div>
<div class="fee-info">
<i class="icon-dollar"></i>
${method.fee === 0 ? 'No fees' : '$' + method.fee.toFixed(2)}
</div>
<div if.bind="method.requiresVerification" class="verification-required">
<i class="icon-shield"></i>
Verification required
</div>
</div>
</div>
</label>
</div>
</div>
<!-- Selection Summary -->
<div if.bind="selectedPaymentMethod" class="selection-summary">
<h4>Payment Summary</h4>
<div class="summary-details">
<div class="summary-row">
<span>Method:</span>
<span>${selectedPaymentMethod.name}</span>
</div>
<div class="summary-row">
<span>Processing:</span>
<span>${selectedPaymentMethod.processingTime}</span>
</div>
<div class="summary-row">
<span>Fee:</span>
<span>${totalFee === 0 ? 'Free' : '$' + totalFee.toFixed(2)}</span>
</div>
<div if.bind="requiresUserVerification" class="verification-notice">
<i class="icon-warning"></i>
This payment method requires account verification
</div>
</div>
</div>
</form>
Advanced Select Elements with Smart Filtering
Sophisticated select components with search, grouping, and virtual scrolling:
interface SelectOption {
value: any;
label: string;
group?: string;
disabled?: boolean;
metadata?: any;
}
export class AdvancedSelectComponent {
countries: SelectOption[] = [];
filteredCountries: SelectOption[] = [];
selectedCountry: SelectOption | null = null;
searchTerm = '';
isLoading = false;
showDropdown = false;
// Grouping and filtering
groupBy = 'region';
sortBy = 'name';
async loadCountries() {
this.isLoading = true;
try {
const response = await fetch('/api/countries');
const data = await response.json();
this.countries = data.map(country => ({
value: country.code,
label: country.name,
group: country.region,
metadata: {
population: country.population,
currency: country.currency,
flag: country.flag
}
}));
this.applyFiltering();
} catch (error) {
console.error('Failed to load countries:', error);
} finally {
this.isLoading = false;
}
}
searchTermChanged() {
this.applyFiltering();
}
applyFiltering() {
let filtered = this.countries;
// Apply search filter
if (this.searchTerm) {
const term = this.searchTerm.toLowerCase();
filtered = filtered.filter(country =>
country.label.toLowerCase().includes(term) ||
country.group?.toLowerCase().includes(term)
);
}
// Apply grouping and sorting
if (this.groupBy) {
filtered = this.sortByGroup(filtered, this.groupBy);
}
this.filteredCountries = filtered;
}
private sortByGroup(options: SelectOption[], groupField: string): SelectOption[] {
const groups = new Map<string, SelectOption[]>();
// Group options
options.forEach(option => {
const groupKey = option.group || 'Other';
if (!groups.has(groupKey)) {
groups.set(groupKey, []);
}
groups.get(groupKey)!.push(option);
});
// Sort within groups and flatten
const sortedOptions: SelectOption[] = [];
Array.from(groups.keys()).sort().forEach(groupKey => {
const groupOptions = groups.get(groupKey)!
.sort((a, b) => a.label.localeCompare(b.label));
sortedOptions.push(...groupOptions);
});
return sortedOptions;
}
selectOption(option: SelectOption) {
if (option.disabled) return;
this.selectedCountry = option;
this.showDropdown = false;
this.searchTerm = '';
}
// Custom matcher for complex object comparison
countryMatcher = (a: SelectOption, b: SelectOption) => {
return a?.value === b?.value;
};
// Virtual scrolling configuration for large lists
virtualScrollConfig = {
itemHeight: 48,
containerHeight: 300,
overscan: 10
};
}
<div class="advanced-select-container">
<label for="country-select">Select Country</label>
<!-- Custom Select with Search -->
<div class="custom-select" class.bind="{ 'open': showDropdown }">
<div class="select-trigger"
click.trigger="showDropdown = !showDropdown">
<span class="selected-value">
${selectedCountry ? selectedCountry.label : 'Choose a country...'}
</span>
<i class="dropdown-arrow"
class.bind="{ 'rotated': showDropdown }"></i>
</div>
<div class="select-dropdown" if.bind="showDropdown">
<!-- Search Input -->
<div class="search-container">
<input type="text"
value.bind="searchTerm & debounce:200"
placeholder="Search countries..."
class="search-input"
focus.bind="showDropdown" />
<i class="search-icon"></i>
</div>
<!-- Loading State -->
<div if.bind="isLoading" class="loading-state">
<i class="spinner"></i>
Loading countries...
</div>
<!-- Options List with Virtual Scrolling -->
<div class="options-container" if.bind="!isLoading">
<virtual-repeat.for="option of filteredCountries"
virtual-repeat-strategy="virtual-repeat-strategy-array">
<div class="select-option"
class.bind="{
'disabled': option.disabled,
'selected': selectedCountry?.value === option.value
}"
click.trigger="selectOption(option)">
<!-- Group Header -->
<div if.bind="$index === 0 || filteredCountries[$index-1].group !== option.group"
class="group-header">
${option.group || 'Other'}
</div>
<!-- Option Content -->
<div class="option-content">
<div class="option-main">
<span if.bind="option.metadata?.flag"
class="flag">${option.metadata.flag}</span>
<span class="option-label">${option.label}</span>
</div>
<div class="option-details" if.bind="option.metadata">
<small class="population">
Pop: ${option.metadata.population | number:'0,000'}
</small>
<small class="currency">
${option.metadata.currency}
</small>
</div>
</div>
</div>
</virtual-repeat>
</div>
<!-- No Results State -->
<div if.bind="!isLoading && filteredCountries.length === 0"
class="no-results">
No countries found matching "${searchTerm}"
</div>
</div>
</div>
<!-- Traditional Select (Fallback) -->
<select if.bind="!useAdvancedSelect"
value.bind="selectedCountry"
matcher.bind="countryMatcher"
class="traditional-select">
<option value="">Choose a country...</option>
<optgroup repeat.for="group of groupedCountries"
label.bind="group.name">
<option repeat.for="country of group.options"
model.bind="country"
disabled.bind="country.disabled">
${country.label}
</option>
</optgroup>
</select>
</div>
Form Submission Patterns
Modern web applications require sophisticated form submission strategies that handle success, failure, loading states, and complex business logic. Aurelia provides flexible patterns for all scenarios.
Basic Form Submission with State Management
Implement comprehensive submission state management for better user experience:
import { resolve, newInstanceForScope } from '@aurelia/kernel';
import { IValidationController } from '@aurelia/validation-html';
interface SubmissionState {
isSubmitting: boolean;
success: boolean;
error: string | null;
attempts: number;
lastSubmission: Date | null;
}
export class ComprehensiveFormSubmission {
formData = {
firstName: '',
lastName: '',
email: '',
message: ''
};
submissionState: SubmissionState = {
isSubmitting: false,
success: false,
error: null,
attempts: 0,
lastSubmission: null
};
private readonly validationController = resolve(newInstanceForScope(IValidationController));
// Rate limiting
private readonly maxAttempts = 3;
private readonly submissionCooldown = 30000; // 30 seconds
get canSubmit(): boolean {
return !this.submissionState.isSubmitting
&& this.submissionState.attempts < this.maxAttempts
&& this.isWithinCooldown();
}
get submissionMessage(): string {
if (this.submissionState.isSubmitting) {
return 'Submitting your form...';
}
if (this.submissionState.success) {
return 'Form submitted successfully!';
}
if (this.submissionState.error) {
return `Submission failed: ${this.submissionState.error}`;
}
if (this.submissionState.attempts >= this.maxAttempts) {
return `Maximum attempts reached. Please try again later.`;
}
return '';
}
async handleSubmit(event: Event) {
event.preventDefault();
if (!this.canSubmit) {
return false; // Prevent form submission
}
// Reset previous state
this.submissionState.error = null;
this.submissionState.success = false;
this.submissionState.isSubmitting = true;
try {
// Validate form before submission
const validationResult = await this.validationController.validate();
if (!validationResult.valid) {
throw new Error('Please fix validation errors before submitting');
}
// Submit form data
const response = await this.submitFormData(this.formData);
// Handle success
this.submissionState.success = true;
this.submissionState.lastSubmission = new Date();
// Optional: Redirect or show success message
setTimeout(() => {
this.resetForm();
}, 2000);
} catch (error) {
// Handle submission error
this.submissionState.error = error instanceof Error
? error.message
: 'An unexpected error occurred';
this.submissionState.attempts++;
// Optional: Log error for debugging
console.error('Form submission failed:', error);
} finally {
this.submissionState.isSubmitting = false;
}
return false; // Prevent default browser submission
}
private async submitFormData(data: any): Promise<any> {
const response = await fetch('/api/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
...data,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.message || `HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
}
private isWithinCooldown(): boolean {
if (!this.submissionState.lastSubmission) return true;
const timeSinceLastSubmission = Date.now() - this.submissionState.lastSubmission.getTime();
return timeSinceLastSubmission > this.submissionCooldown;
}
resetForm() {
this.formData = {
firstName: '',
lastName: '',
email: '',
message: ''
};
this.submissionState = {
isSubmitting: false,
success: false,
error: null,
attempts: 0,
lastSubmission: null
};
// Clear validation errors
this.validationController.reset();
}
retrySubmission() {
if (this.submissionState.attempts < this.maxAttempts) {
this.submissionState.error = null;
// Allow retry by not resetting attempts - they'll try again
}
}
}
<form submit.trigger="handleSubmit($event)" class="comprehensive-form">
<h2>Contact Us</h2>
<!-- Form Fields -->
<div class="form-row">
<div class="form-group col-md-6">
<label for="firstName">First Name</label>
<input id="firstName"
type="text"
value.bind="formData.firstName & validate"
disabled.bind="submissionState.isSubmitting"
class="form-control" />
</div>
<div class="form-group col-md-6">
<label for="lastName">Last Name</label>
<input id="lastName"
type="text"
value.bind="formData.lastName & validate"
disabled.bind="submissionState.isSubmitting"
class="form-control" />
</div>
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input id="email"
type="email"
value.bind="formData.email & validate"
disabled.bind="submissionState.isSubmitting"
class="form-control" />
</div>
<div class="form-group">
<label for="message">Message</label>
<textarea id="message"
rows="5"
value.bind="formData.message & validate"
disabled.bind="submissionState.isSubmitting"
class="form-control"></textarea>
</div>
<!-- Submission State Display -->
<div class="submission-status">
<div if.bind="submissionState.isSubmitting" class="alert alert-info">
<div class="loading-spinner"></div>
${submissionMessage}
</div>
<div if.bind="submissionState.success" class="alert alert-success">
<i class="icon-check"></i>
${submissionMessage}
</div>
<div if.bind="submissionState.error" class="alert alert-danger">
<i class="icon-warning"></i>
${submissionMessage}
<div class="retry-section">
<button type="button"
click.trigger="retrySubmission()"
class="btn btn-sm btn-outline-danger"
if.bind="submissionState.attempts < maxAttempts">
Try Again (${maxAttempts - submissionState.attempts} attempts remaining)
</button>
</div>
</div>
<div if.bind="submissionState.attempts >= maxAttempts" class="alert alert-warning">
<i class="icon-clock"></i>
Too many attempts. Please try again in
${Math.ceil((submissionCooldown - (Date.now() - submissionState.lastSubmission?.getTime())) / 1000)} seconds.
</div>
</div>
<!-- Form Actions -->
<div class="form-actions">
<button type="submit"
disabled.bind="!canSubmit"
class="btn btn-primary"
class.bind="{ 'btn-loading': submissionState.isSubmitting }">
<span if.bind="submissionState.isSubmitting">Submitting...</span>
<span if.bind="!submissionState.isSubmitting">Send Message</span>
</button>
<button type="button"
click.trigger="resetForm()"
disabled.bind="submissionState.isSubmitting"
class="btn btn-secondary">
Reset Form
</button>
</div>
<!-- Submission History (Development) -->
<div if.bind="showDebugInfo" class="debug-section">
<h4>Submission Debug Info</h4>
<ul>
<li>Attempts: ${submissionState.attempts}/${maxAttempts}</li>
<li>Last Submission: ${submissionState.lastSubmission?.toLocaleString() || 'Never'}</li>
<li>Can Submit: ${canSubmit ? 'Yes' : 'No'}</li>
<li>Is Submitting: ${submissionState.isSubmitting ? 'Yes' : 'No'}</li>
</ul>
</div>
</form>
Multi-Step Form Submission
Handle complex multi-step forms with progress tracking and validation:
interface FormStep {
id: string;
title: string;
component: string;
isValid: boolean;
isComplete: boolean;
data: any;
}
export class MultiStepFormSubmission {
currentStepIndex = 0;
isSubmitting = false;
submissionProgress = 0;
private readonly validationController = resolve(newInstanceForScope(IValidationController));
steps: FormStep[] = [
{
id: 'personal',
title: 'Personal Information',
component: 'personal-info-step',
isValid: false,
isComplete: false,
data: {}
},
{
id: 'account',
title: 'Account Details',
component: 'account-details-step',
isValid: false,
isComplete: false,
data: {}
},
{
id: 'preferences',
title: 'Preferences',
component: 'preferences-step',
isValid: false,
isComplete: false,
data: {}
},
{
id: 'confirmation',
title: 'Confirmation',
component: 'confirmation-step',
isValid: true,
isComplete: false,
data: {}
}
];
get currentStep(): FormStep {
return this.steps[this.currentStepIndex];
}
get isFirstStep(): boolean {
return this.currentStepIndex === 0;
}
get isLastStep(): boolean {
return this.currentStepIndex === this.steps.length - 1;
}
get canProceed(): boolean {
return this.currentStep.isValid && !this.isSubmitting;
}
get canGoBack(): boolean {
return !this.isFirstStep && !this.isSubmitting;
}
get overallProgress(): number {
const completedSteps = this.steps.filter(step => step.isComplete).length;
return (completedSteps / this.steps.length) * 100;
}
async nextStep() {
if (!this.canProceed) return;
// Validate current step
const isValid = await this.validateCurrentStep();
if (!isValid) return;
// Mark current step as complete
this.currentStep.isComplete = true;
if (this.isLastStep) {
// Submit the form
await this.submitCompleteForm();
} else {
// Move to next step
this.currentStepIndex++;
}
}
previousStep() {
if (this.canGoBack) {
this.currentStepIndex--;
}
}
goToStep(stepIndex: number) {
if (stepIndex >= 0 && stepIndex < this.steps.length) {
// Only allow going to completed steps or next step
const targetStep = this.steps[stepIndex];
const canNavigateToStep = targetStep.isComplete ||
stepIndex === this.currentStepIndex + 1;
if (canNavigateToStep) {
this.currentStepIndex = stepIndex;
}
}
}
private async validateCurrentStep(): Promise<boolean> {
const result = await this.validationController.validate();
this.currentStep.isValid = result.valid;
return result.valid;
}
private async submitCompleteForm() {
this.isSubmitting = true;
this.submissionProgress = 0;
try {
// Collect all form data
const formData = this.steps.reduce((acc, step) => {
return { ...acc, [step.id]: step.data };
}, {});
// Submit with progress tracking
await this.submitWithProgress(formData);
// Mark final step as complete
this.currentStep.isComplete = true;
this.submissionProgress = 100;
// Show success message
console.log('Multi-step form submitted successfully!');
} catch (error) {
console.error('Form submission failed:', error);
// Handle error appropriately
} finally {
this.isSubmitting = false;
}
}
private async submitWithProgress(data: any): Promise<void> {
// Simulate progress updates
const progressSteps = [
{ message: 'Validating data...', progress: 20 },
{ message: 'Processing payment...', progress: 50 },
{ message: 'Creating account...', progress: 75 },
{ message: 'Sending confirmation...', progress: 90 },
{ message: 'Complete!', progress: 100 }
];
for (const step of progressSteps) {
await new Promise(resolve => setTimeout(resolve, 800));
this.submissionProgress = step.progress;
console.log(step.message);
}
// Actual submission would happen here
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(`Registration failed: ${response.statusText}`);
}
}
updateStepData(data: any) {
Object.assign(this.currentStep.data, data);
}
resetForm() {
this.currentStepIndex = 0;
this.isSubmitting = false;
this.submissionProgress = 0;
this.steps.forEach(step => {
step.isValid = step.id === 'confirmation'; // Only confirmation step is valid by default
step.isComplete = false;
step.data = {};
});
}
}
<div class="multi-step-form-container">
<div class="form-header">
<h2>Account Registration</h2>
<!-- Progress Indicator -->
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill"
style="width: ${overallProgress}%"></div>
</div>
<div class="progress-text">${overallProgress.toFixed(0)}% Complete</div>
</div>
<!-- Step Navigation -->
<nav class="step-navigation">
<div repeat.for="step of steps"
class="step-indicator"
class.bind="{
'active': $index === currentStepIndex,
'completed': step.isComplete,
'valid': step.isValid
}"
click.trigger="goToStep($index)">
<div class="step-number">${$index + 1}</div>
<div class="step-title">${step.title}</div>
</div>
</nav>
</div>
<!-- Dynamic Step Content -->
<div class="step-content">
<compose
view-model.bind="currentStep.component"
model.bind="{
data: currentStep.data,
updateData: updateStepData,
isValid: currentStep.isValid
}">
</compose>
</div>
<!-- Submission Progress -->
<div if.bind="isSubmitting" class="submission-progress">
<h4>Processing Your Registration</h4>
<div class="progress-bar">
<div class="progress-fill"
style="width: ${submissionProgress}%"></div>
</div>
<div class="progress-text">${submissionProgress}% Complete</div>
</div>
<!-- Navigation Controls -->
<div class="form-navigation" if.bind="!isSubmitting">
<button type="button"
click.trigger="previousStep()"
disabled.bind="!canGoBack"
class="btn btn-secondary">
← Previous
</button>
<button type="button"
click.trigger="nextStep()"
disabled.bind="!canProceed"
class="btn btn-primary">
<span if.bind="!isLastStep">Next →</span>
<span if.bind="isLastStep">Submit Registration</span>
</button>
</div>
<!-- Form Actions -->
<div class="form-actions">
<button type="button"
click.trigger="resetForm()"
disabled.bind="isSubmitting"
class="btn btn-outline-secondary">
Start Over
</button>
</div>
</div>
File Inputs and Upload Handling
Working with file uploads in Aurelia typically involves using the standard <input type="file">
element and handling file data in your view model. While Aurelia doesn’t provide special bindings for file inputs, you can easily wire up event handlers or use standard properties to capture and upload files.
Capturing File Data
In most cases, you’ll want to listen for the change
event on a file input:
<form>
<label for="fileUpload">Select files to upload:</label>
<input
id="fileUpload"
type="file"
multiple
accept="image/*"
change.trigger="handleFileSelect($event)"
/>
<button click.trigger="uploadFiles()" disabled.bind="!selectedFiles.length">
Upload
</button>
</form>
multiple
: Allows selecting more than one file.accept="image/*"
: Restricts file selection to images (this can be changed to fit your needs).change.trigger="handleFileSelect($event)"
: Calls a method in your view model to handle the file selection event.
View Model Handling
You can retrieve the selected files from the event object in your view model:
export class FileUploadComponent {
public selectedFiles: File[] = [];
public handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files?.length) {
return;
}
// Convert the FileList to a real array
this.selectedFiles = Array.from(input.files);
}
public async uploadFiles() {
if (this.selectedFiles.length === 0) {
return;
}
const formData = new FormData();
for (const file of this.selectedFiles) {
// The first argument (key) matches the field name expected by your backend
formData.append('files', file, file.name);
}
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`Upload failed with status ${response.status}`);
}
const result = await response.json();
console.log('Upload successful:', result);
// Optionally, reset selected files
this.selectedFiles = [];
} catch (error) {
console.error('Error uploading files:', error);
}
}
}
Key Points:
Reading File Data:
input.files
returns aFileList
; converting it to an array (Array.from
) makes it easier to iterate over.FormData: Using
FormData
to append files is a convenient way to send them to the server (via Fetch).Error Handling: Always check
response.ok
to handle server or network errors.Disabling the Button: In the HTML,
disabled.bind="!selectedFiles.length"
keeps the button disabled until at least one file is selected.
Single File Inputs
If you only need a single file, omit multiple and simplify your logic:
<input type="file" accept="image/*" change.trigger="handleFileSelect($event)" />
public handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
this.selectedFiles = input.files?.length ? [input.files[0]] : [];
}
Validation and Security
When handling file uploads, consider adding validation and security measures:
Server-side Validation: Even if you filter files by type on the client (accept="image/*"), always verify on the server to ensure the files are valid and safe.
File Size Limits: Check file sizes either on the client or server (or both) to prevent excessively large uploads.
Progress Indicators: For a better user experience, consider using XMLHttpRequest or the Fetch API with progress events (via third-party solutions or polyfills), so you can display an upload progress bar.
Validation Integration
Aurelia's validation system integrates seamlessly with forms through the & validate
binding behavior and specialized validation components. This section covers practical validation patterns for production applications.
Basic Validation with & validate
The & validate
binding behavior automatically integrates form inputs with Aurelia's validation system.
import { resolve, newInstanceForScope } from '@aurelia/kernel';
import { IValidationRules } from '@aurelia/validation';
import { IValidationController } from '@aurelia/validation-html';
export class UserRegistrationForm {
user = {
email: '',
password: '',
confirmPassword: '',
age: null as number | null,
terms: false
};
private readonly validationRules = resolve(IValidationRules);
private readonly validationController = resolve(newInstanceForScope(IValidationController));
constructor() {
// Define validation rules
this.setupValidationRules();
}
private setupValidationRules() {
this.validationRules
.on(this.user)
.ensure('email')
.required()
.email()
.withMessage('Please enter a valid email address')
.ensure('password')
.required()
.minLength(8)
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('Password must contain lowercase, uppercase, and number')
.ensure('confirmPassword')
.required()
.satisfies((value, object) => value === object.password)
.withMessage('Passwords must match')
.ensure('age')
.required()
.range(13, 120)
.withMessage('Age must be between 13 and 120')
.ensure('terms')
.satisfies(value => value === true)
.withMessage('You must accept the terms and conditions');
}
async handleSubmit() {
const result = await this.validationController.validate();
if (result.valid) {
console.log('Form is valid, submitting...', this.user);
// Submit form
} else {
console.log('Validation failed:', result);
}
}
}
<form submit.trigger="handleSubmit()">
<div class="form-group">
<label for="email">Email Address</label>
<input id="email"
type="email"
value.bind="user.email & validate"
class="form-control" />
</div>
<div class="form-group">
<label for="password">Password</label>
<input id="password"
type="password"
value.bind="user.password & validate"
class="form-control" />
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input id="confirmPassword"
type="password"
value.bind="user.confirmPassword & validate"
class="form-control" />
</div>
<div class="form-group">
<label for="age">Age</label>
<input id="age"
type="number"
value.bind="user.age & validate"
class="form-control" />
</div>
<div class="form-group">
<label>
<input type="checkbox" checked.bind="user.terms & validate" />
I accept the terms and conditions
</label>
</div>
<button type="submit" class="btn btn-primary">Register</button>
</form>
Advanced Validation Display
Use validation components for sophisticated error display:
import { resolve, newInstanceForScope } from '@aurelia/kernel';
import { IValidationRules } from '@aurelia/validation';
import { IValidationController, ValidationTrigger } from '@aurelia/validation-html';
export class AdvancedValidationForm {
contact = {
firstName: '',
lastName: '',
email: '',
phone: '',
company: '',
message: ''
};
// Validation controller for manual control
private readonly validationController = resolve(newInstanceForScope(IValidationController));
private readonly validationRules = resolve(IValidationRules);
// Form-specific error tracking
nameErrors: any[] = [];
contactErrors: any[] = [];
messageErrors: any[] = [];
constructor() {
this.setupValidation();
}
private setupValidation() {
this.validationRules
.on(this.contact)
.ensure('firstName')
.required()
.minLength(2)
.withMessage('First name must be at least 2 characters')
.ensure('lastName')
.required()
.minLength(2)
.withMessage('Last name must be at least 2 characters')
.ensure('email')
.required()
.email()
.withMessage('Please enter a valid email address')
.ensure('phone')
.required()
.matches(/^[\d\s\-\+\(\)]+$/)
.withMessage('Please enter a valid phone number')
.ensure('company')
.required()
.withMessage('Company name is required')
.ensure('message')
.required()
.minLength(10)
.withMessage('Message must be at least 10 characters');
// Configure validation triggers
this.validationController.validateTrigger = ValidationTrigger.changeOrBlur;
}
async validateSection(sectionName: 'name' | 'contact' | 'message') {
let properties: string[] = [];
switch (sectionName) {
case 'name':
properties = ['firstName', 'lastName'];
break;
case 'contact':
properties = ['email', 'phone', 'company'];
break;
case 'message':
properties = ['message'];
break;
}
const result = await this.validationController.validate({
object: this.contact,
propertyName: properties
});
// Update section-specific errors
this[`${sectionName}Errors`] = result.results
.filter(r => !r.valid)
.map(r => ({ error: r, target: r.target }));
return result.valid;
}
async submitForm() {
const result = await this.validationController.validate();
if (result.valid) {
// Submit the form
console.log('Submitting:', this.contact);
}
}
}
<form class="advanced-form">
<!-- Name Section with Validation Container -->
<validation-container class="form-section">
<h3>Personal Information</h3>
<div validation-errors.bind="nameErrors" class="section-errors">
<div repeat.for="error of nameErrors" class="alert alert-danger">
${error.error.message}
</div>
</div>
<div class="form-row">
<div class="form-group col-md-6">
<label for="firstName">First Name</label>
<input id="firstName"
value.bind="contact.firstName & validate"
class="form-control" />
</div>
<div class="form-group col-md-6">
<label for="lastName">Last Name</label>
<input id="lastName"
value.bind="contact.lastName & validate"
class="form-control" />
</div>
</div>
<button type="button"
click.trigger="validateSection('name')"
class="btn btn-outline-primary">
Validate Name Section
</button>
</validation-container>
<!-- Contact Section -->
<validation-container class="form-section">
<h3>Contact Information</h3>
<div validation-errors.bind="contactErrors" class="section-errors">
<div repeat.for="error of contactErrors" class="alert alert-danger">
${error.error.message}
</div>
</div>
<div class="form-group">
<label for="email">Email Address</label>
<input id="email"
type="email"
value.bind="contact.email & validate"
class="form-control" />
</div>
<div class="form-group">
<label for="phone">Phone Number</label>
<input id="phone"
type="tel"
value.bind="contact.phone & validate"
class="form-control" />
</div>
<div class="form-group">
<label for="company">Company</label>
<input id="company"
value.bind="contact.company & validate"
class="form-control" />
</div>
</validation-container>
<!-- Message Section -->
<validation-container class="form-section">
<h3>Message</h3>
<div validation-errors.bind="messageErrors" class="section-errors">
<div repeat.for="error of messageErrors" class="alert alert-danger">
${error.error.message}
</div>
</div>
<div class="form-group">
<label for="message">Your Message</label>
<textarea id="message"
rows="5"
value.bind="contact.message & validate"
class="form-control"
placeholder="Tell us about your needs..."></textarea>
</div>
</validation-container>
<!-- Form Actions -->
<div class="form-actions">
<button type="button"
click.trigger="submitForm()"
class="btn btn-primary btn-lg">
Send Message
</button>
</div>
</form>
Dynamic Validation Rules
Create validation rules that adapt to changing form conditions:
import { resolve, newInstanceForScope } from '@aurelia/kernel';
import { IValidationRules } from '@aurelia/validation';
import { IValidationController } from '@aurelia/validation-html';
export class DynamicValidationForm {
profile = {
userType: 'individual' as 'individual' | 'business',
firstName: '',
lastName: '',
businessName: '',
taxId: '',
email: '',
phone: ''
};
private readonly validationRules = resolve(IValidationRules);
private readonly validationController = resolve(newInstanceForScope(IValidationController));
constructor() {
this.setupDynamicValidation();
}
private setupDynamicValidation() {
this.validationRules
.on(this.profile)
.ensure('firstName')
.required()
.when(obj => obj.userType === 'individual')
.withMessage('First name is required for individuals')
.ensure('lastName')
.required()
.when(obj => obj.userType === 'individual')
.withMessage('Last name is required for individuals')
.ensure('businessName')
.required()
.when(obj => obj.userType === 'business')
.withMessage('Business name is required for businesses')
.ensure('taxId')
.required()
.matches(/^\d{2}-\d{7}$/)
.when(obj => obj.userType === 'business')
.withMessage('Tax ID must be in format XX-XXXXXXX')
.ensure('email')
.required()
.email()
.withMessage('Valid email address is required')
.ensure('phone')
.required()
.matches(/^[\d\s\-\+\(\)]+$/)
.withMessage('Valid phone number is required');
}
userTypeChanged() {
// Re-validate when user type changes
this.validationController.validate();
}
async handleSubmit() {
const result = await this.validationController.validate();
if (result.valid) {
console.log('Submitting profile:', this.profile);
// Handle successful validation
} else {
console.log('Validation errors:', result.results.filter(r => !r.valid));
}
}
}
<form submit.trigger="handleSubmit()" class="dynamic-form">
<div class="form-group">
<label>Account Type</label>
<div class="form-check-container">
<label class="form-check">
<input type="radio"
name="userType"
model.bind="'individual'"
checked.bind="profile.userType"
change.trigger="userTypeChanged()" />
Individual
</label>
<label class="form-check">
<input type="radio"
name="userType"
model.bind="'business'"
checked.bind="profile.userType"
change.trigger="userTypeChanged()" />
Business
</label>
</div>
</div>
<!-- Individual Fields -->
<div if.bind="profile.userType === 'individual'" class="user-type-section">
<h4>Personal Information</h4>
<div class="form-row">
<div class="form-group col-md-6">
<label for="firstName">First Name</label>
<input id="firstName"
value.bind="profile.firstName & validate"
class="form-control" />
</div>
<div class="form-group col-md-6">
<label for="lastName">Last Name</label>
<input id="lastName"
value.bind="profile.lastName & validate"
class="form-control" />
</div>
</div>
</div>
<!-- Business Fields -->
<div if.bind="profile.userType === 'business'" class="user-type-section">
<h4>Business Information</h4>
<div class="form-group">
<label for="businessName">Business Name</label>
<input id="businessName"
value.bind="profile.businessName & validate"
class="form-control" />
</div>
<div class="form-group">
<label for="taxId">Tax ID (XX-XXXXXXX)</label>
<input id="taxId"
value.bind="profile.taxId & validate"
class="form-control"
placeholder="12-3456789" />
</div>
</div>
<!-- Common Fields -->
<div class="common-fields">
<h4>Contact Information</h4>
<div class="form-group">
<label for="email">Email Address</label>
<input id="email"
type="email"
value.bind="profile.email & validate"
class="form-control" />
</div>
<div class="form-group">
<label for="phone">Phone Number</label>
<input id="phone"
type="tel"
value.bind="profile.phone & validate"
class="form-control" />
</div>
</div>
<button type="submit" class="btn btn-primary">Submit Profile</button>
</form>
Real-time Validation Feedback
Provide immediate feedback with sophisticated validation timing:
import { resolve, newInstanceForScope } from '@aurelia/kernel';
import { IValidationRules } from '@aurelia/validation';
import { IValidationController } from '@aurelia/validation-html';
// Utility function for debouncing (you would typically import this from a utility library)
function debounce(func: Function, wait: number) {
let timeout: any;
return function executedFunction(...args: any[]) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
export class RealTimeValidationForm {
user = {
username: '',
email: '',
password: '',
confirmPassword: ''
};
validationStates = {
username: { checking: false, available: false, message: '' },
email: { checking: false, valid: false, message: '' },
password: { strength: 0, message: '' },
confirmPassword: { matches: false, message: '' }
};
private readonly validationRules = resolve(IValidationRules);
private readonly validationController = resolve(newInstanceForScope(IValidationController));
private debounceUsernameCheck = debounce(this.checkUsernameAvailability.bind(this), 500);
private debounceEmailCheck = debounce(this.validateEmail.bind(this), 300);
constructor() {
this.setupValidation();
}
private setupValidation() {
this.validationRules
.on(this.user)
.ensure('username')
.required()
.minLength(3)
.matches(/^[a-zA-Z0-9_]+$/)
.satisfies(async (username) => {
if (username.length >= 3) {
return await this.isUsernameAvailable(username);
}
return true;
})
.withMessage('Username must be available')
.ensure('email')
.required()
.email()
.satisfies(async (email) => await this.isEmailValid(email))
.withMessage('Please enter a valid, verified email address')
.ensure('password')
.required()
.minLength(8)
.satisfies(password => this.calculatePasswordStrength(password) >= 3)
.withMessage('Password must be strong (score 3+)')
.ensure('confirmPassword')
.required()
.satisfies((value, obj) => value === obj.password)
.withMessage('Passwords must match');
}
usernameChanged(newUsername: string) {
if (newUsername.length >= 3) {
this.validationStates.username.checking = true;
this.debounceUsernameCheck(newUsername);
}
}
emailChanged(newEmail: string) {
if (newEmail.includes('@')) {
this.validationStates.email.checking = true;
this.debounceEmailCheck(newEmail);
}
}
passwordChanged(newPassword: string) {
const strength = this.calculatePasswordStrength(newPassword);
this.validationStates.password.strength = strength;
this.validationStates.password.message = this.getPasswordStrengthMessage(strength);
// Re-validate confirm password
if (this.user.confirmPassword) {
this.confirmPasswordChanged(this.user.confirmPassword);
}
}
confirmPasswordChanged(confirmPassword: string) {
const matches = confirmPassword === this.user.password;
this.validationStates.confirmPassword.matches = matches;
this.validationStates.confirmPassword.message = matches
? 'Passwords match'
: 'Passwords do not match';
}
private async checkUsernameAvailability(username: string) {
try {
const available = await this.isUsernameAvailable(username);
this.validationStates.username = {
checking: false,
available,
message: available ? 'Username is available' : 'Username is taken'
};
} catch (error) {
this.validationStates.username = {
checking: false,
available: false,
message: 'Error checking username availability'
};
}
}
private async validateEmail(email: string) {
try {
const valid = await this.isEmailValid(email);
this.validationStates.email = {
checking: false,
valid,
message: valid ? 'Email is valid' : 'Email format is invalid'
};
} catch (error) {
this.validationStates.email = {
checking: false,
valid: false,
message: 'Error validating email'
};
}
}
private calculatePasswordStrength(password: string): number {
let strength = 0;
if (password.length >= 8) strength++;
if (/[a-z]/.test(password)) strength++;
if (/[A-Z]/.test(password)) strength++;
if (/\d/.test(password)) strength++;
if (/[^a-zA-Z\d]/.test(password)) strength++;
return strength;
}
private getPasswordStrengthMessage(strength: number): string {
const messages = [
'Very weak password',
'Weak password',
'Fair password',
'Good password',
'Strong password',
'Very strong password'
];
return messages[strength] || 'No password';
}
// Mock API calls (replace with real implementations)
private async isUsernameAvailable(username: string): Promise<boolean> {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));
return !['admin', 'test', 'user'].includes(username.toLowerCase());
}
private async isEmailValid(email: string): Promise<boolean> {
// Simulate API call for email verification
await new Promise(resolve => setTimeout(resolve, 300));
return email.includes('@') && !email.includes('invalid');
}
}
<form class="realtime-validation-form">
<div class="form-group">
<label for="username">Username</label>
<div class="input-with-feedback">
<input id="username"
value.bind="user.username & validate & debounce:100"
class="form-control"
class.bind="{
'is-valid': validationStates.username.available,
'is-invalid': validationStates.username.message && !validationStates.username.available
}" />
<div class="feedback-icons">
<i if.bind="validationStates.username.checking" class="spinner"></i>
<i if.bind="validationStates.username.available" class="success-icon">✓</i>
<i if.bind="validationStates.username.message && !validationStates.username.available" class="error-icon">✗</i>
</div>
</div>
<div class="feedback-text"
class.bind="{
'text-success': validationStates.username.available,
'text-danger': !validationStates.username.available && validationStates.username.message
}">
${validationStates.username.message}
</div>
</div>
<div class="form-group">
<label for="email">Email Address</label>
<div class="input-with-feedback">
<input id="email"
type="email"
value.bind="user.email & validate & debounce:200"
class="form-control"
class.bind="{
'is-valid': validationStates.email.valid,
'is-invalid': validationStates.email.message && !validationStates.email.valid
}" />
<div class="feedback-icons">
<i if.bind="validationStates.email.checking" class="spinner"></i>
<i if.bind="validationStates.email.valid" class="success-icon">✓</i>
<i if.bind="validationStates.email.message && !validationStates.email.valid" class="error-icon">✗</i>
</div>
</div>
<div class="feedback-text"
class.bind="{
'text-success': validationStates.email.valid,
'text-danger': !validationStates.email.valid && validationStates.email.message
}">
${validationStates.email.message}
</div>
</div>
<div class="form-group">
<label for="password">Password</label>
<input id="password"
type="password"
value.bind="user.password & validate"
class="form-control" />
<div class="password-strength">
<div class="strength-bar">
<div repeat.for="i of 5"
class="strength-segment"
class.bind="{
'active': i < validationStates.password.strength,
'weak': validationStates.password.strength <= 2,
'medium': validationStates.password.strength === 3,
'strong': validationStates.password.strength >= 4
}"></div>
</div>
<div class="strength-text">${validationStates.password.message}</div>
</div>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<div class="input-with-feedback">
<input id="confirmPassword"
type="password"
value.bind="user.confirmPassword & validate"
class="form-control"
class.bind="{
'is-valid': validationStates.confirmPassword.matches && user.confirmPassword,
'is-invalid': !validationStates.confirmPassword.matches && user.confirmPassword
}" />
<div class="feedback-icons">
<i if.bind="validationStates.confirmPassword.matches && user.confirmPassword" class="success-icon">✓</i>
<i if.bind="!validationStates.confirmPassword.matches && user.confirmPassword" class="error-icon">✗</i>
</div>
</div>
<div class="feedback-text"
class.bind="{
'text-success': validationStates.confirmPassword.matches,
'text-danger': !validationStates.confirmPassword.matches && user.confirmPassword
}">
${validationStates.confirmPassword.message}
</div>
</div>
<button type="submit" class="btn btn-primary">Create Account</button>
</form>
For comprehensive validation documentation, see the dedicated Validation Guide.
Security and Best Practices
Security in forms is critical for protecting user data and preventing common web vulnerabilities. Aurelia provides the foundation for secure form implementations, but you must implement security best practices.
Input Validation and Sanitization
Always validate and sanitize user input on both client and server sides:
import { resolve } from '@aurelia/kernel';
import { IValidationRules } from '@aurelia/validation';
export class SecureFormComponent {
private readonly maxFieldLength = 1000;
private readonly allowedFileTypes = ['image/jpeg', 'image/png', 'image/webp'];
private readonly maxFileSize = 5 * 1024 * 1024; // 5MB
userData = {
username: '',
email: '',
bio: '',
website: ''
};
private readonly validationRules = resolve(IValidationRules);
constructor() {
this.setupSecureValidation();
}
private setupSecureValidation() {
this.validationRules
.on(this.userData)
.ensure('username')
.required()
.minLength(3)
.maxLength(20)
.matches(/^[a-zA-Z0-9_-]+$/)
.withMessage('Username can only contain letters, numbers, underscores, and hyphens')
.satisfies(username => this.isUsernameSafe(username))
.withMessage('Username contains prohibited content')
.ensure('email')
.required()
.email()
.maxLength(254) // RFC 5321 limit
.satisfies(email => this.isEmailDomainAllowed(email))
.withMessage('Email domain not allowed')
.ensure('bio')
.maxLength(this.maxFieldLength)
.satisfies(bio => this.containsNoMaliciousContent(bio))
.withMessage('Bio contains prohibited content')
.ensure('website')
.satisfies(url => !url || this.isUrlSafe(url))
.withMessage('Website URL is not allowed');
}
// Input sanitization
sanitizeInput(input: string): string {
// Remove potentially dangerous characters
let sanitized = input.trim();
// Remove null bytes
sanitized = sanitized.replace(/\0/g, '');
// Limit length
sanitized = sanitized.substring(0, this.maxFieldLength);
// HTML encode for display (use a proper HTML sanitizer in production)
sanitized = sanitized
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
return sanitized;
}
// Security validation methods
private isUsernameSafe(username: string): boolean {
// Check against prohibited usernames
const prohibitedUsernames = ['admin', 'root', 'administrator', 'system'];
return !prohibitedUsernames.includes(username.toLowerCase());
}
private isEmailDomainAllowed(email: string): boolean {
// Example: Block certain domains (implement your own logic)
const blockedDomains = ['tempmail.com', 'guerrillamail.com'];
const domain = email.split('@')[1]?.toLowerCase();
return domain ? !blockedDomains.includes(domain) : false;
}
private containsNoMaliciousContent(text: string): boolean {
// Check for common XSS patterns
const dangerousPatterns = [
/<script/i,
/javascript:/i,
/on\w+\s*=/i,
/<iframe/i,
/<object/i,
/<embed/i
];
return !dangerousPatterns.some(pattern => pattern.test(text));
}
private isUrlSafe(url: string): boolean {
try {
const parsedUrl = new URL(url);
// Only allow HTTP and HTTPS
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return false;
}
// Check for dangerous domains (implement your own logic)
const dangerousDomains = ['malicious-site.com'];
return !dangerousDomains.includes(parsedUrl.hostname.toLowerCase());
} catch {
return false; // Invalid URL
}
}
// Secure file upload validation
validateFile(file: File): { isValid: boolean; error?: string } {
// Check file type
if (!this.allowedFileTypes.includes(file.type)) {
return {
isValid: false,
error: 'File type not allowed. Only JPEG, PNG, and WebP images are permitted.'
};
}
// Check file size
if (file.size > this.maxFileSize) {
return {
isValid: false,
error: `File size exceeds limit. Maximum size is ${this.maxFileSize / (1024 * 1024)}MB.`
};
}
// Check filename for dangerous characters
if (/[<>:"\\|?*\x00-\x1f]/.test(file.name)) {
return {
isValid: false,
error: 'Filename contains invalid characters.'
};
}
return { isValid: true };
}
async handleSecureSubmit() {
try {
// Sanitize all inputs before submission
const sanitizedData = {
username: this.sanitizeInput(this.userData.username),
email: this.userData.email.toLowerCase().trim(),
bio: this.sanitizeInput(this.userData.bio),
website: this.userData.website.trim()
};
// Add security headers and CSRF token
const response = await fetch('/api/secure-form', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': this.getCSRFToken(),
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(sanitizedData)
});
if (!response.ok) {
throw new Error('Server validation failed');
}
const result = await response.json();
console.log('Form submitted securely:', result);
} catch (error) {
console.error('Secure submission failed:', error);
// Don't expose internal error details to user
throw new Error('Submission failed. Please try again.');
}
}
private getCSRFToken(): string {
// Get CSRF token from meta tag or cookie
const metaTag = document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement;
return metaTag?.content || '';
}
}
Rate Limiting and Abuse Prevention
Implement client-side rate limiting and abuse prevention:
export class RateLimitedForm {
private submissionAttempts: number = 0;
private lastSubmissionTime: number = 0;
private readonly maxAttempts: number = 5;
private readonly timeWindow: number = 60000; // 1 minute
private readonly baseDelay: number = 1000; // 1 second
get currentDelay(): number {
// Exponential backoff
return this.baseDelay * Math.pow(2, this.submissionAttempts);
}
get canSubmit(): boolean {
const now = Date.now();
// Reset attempts if time window has passed
if (now - this.lastSubmissionTime > this.timeWindow) {
this.submissionAttempts = 0;
}
return this.submissionAttempts < this.maxAttempts;
}
async handleRateLimitedSubmit() {
if (!this.canSubmit) {
const waitTime = Math.ceil(this.currentDelay / 1000);
throw new Error(`Too many attempts. Please wait ${waitTime} seconds.`);
}
this.submissionAttempts++;
this.lastSubmissionTime = Date.now();
try {
await this.submitWithDelay();
// Reset on successful submission
this.submissionAttempts = 0;
} catch (error) {
// Keep attempt count for failed submissions
throw error;
}
}
private async submitWithDelay() {
// Add artificial delay to prevent rapid-fire submissions
if (this.submissionAttempts > 1) {
await new Promise(resolve => setTimeout(resolve, this.currentDelay));
}
// Actual submission logic here
const response = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.formData)
});
if (!response.ok) {
throw new Error('Submission failed');
}
return response.json();
}
}
Content Security Policy (CSP) Considerations
Implement CSP-friendly form patterns:
export class CSPFriendlyForm {
// Avoid inline event handlers - use Aurelia's binding instead
// BAD: <button onclick="handleClick()">
// GOOD: <button click.trigger="handleClick()">
// Use nonce for dynamic content if needed
private nonce: string = this.generateNonce();
private generateNonce(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
}
// Safe dynamic script injection (if absolutely necessary)
addScriptSecurely(scriptContent: string) {
const script = document.createElement('script');
script.nonce = this.nonce;
script.textContent = scriptContent;
document.head.appendChild(script);
}
}
Accessibility Considerations
Building accessible forms ensures your application works for users with disabilities and meets WCAG guidelines. Aurelia provides excellent support for accessibility features.
Semantic Form Structure
Use proper semantic HTML and ARIA attributes:
<form class="accessible-form" role="form" aria-labelledby="form-title">
<h2 id="form-title">Contact Information Form</h2>
<p id="form-description">Please provide your contact details. Required fields are marked with an asterisk (*).</p>
<!-- Fieldset for grouping related fields -->
<fieldset>
<legend>Personal Information</legend>
<div class="form-group">
<label for="firstName" class="required">
First Name *
<span class="visually-hidden">(required)</span>
</label>
<input id="firstName"
type="text"
value.bind="contact.firstName & validate"
required
aria-describedby="firstName-help firstName-error"
aria-invalid.bind="hasFirstNameError"
class="form-control" />
<div id="firstName-help" class="help-text">
Enter your legal first name as it appears on official documents
</div>
<div id="firstName-error"
class="error-message"
role="alert"
if.bind="hasFirstNameError"
aria-live="polite">
${firstNameError}
</div>
</div>
<div class="form-group">
<label for="email" class="required">
Email Address *
<span class="visually-hidden">(required)</span>
</label>
<input id="email"
type="email"
value.bind="contact.email & validate"
required
aria-describedby="email-help"
autocomplete="email"
class="form-control" />
<div id="email-help" class="help-text">
We'll use this to send you important updates
</div>
</div>
<div class="form-group">
<label for="phone">Phone Number (Optional)</label>
<input id="phone"
type="tel"
value.bind="contact.phone & validate"
aria-describedby="phone-help"
autocomplete="tel"
class="form-control" />
<div id="phone-help" class="help-text">
Include country code for international numbers
</div>
</div>
</fieldset>
<!-- Radio group with proper ARIA -->
<fieldset>
<legend>Preferred Contact Method</legend>
<div class="radio-group" role="radiogroup" aria-required="true">
<div class="form-check">
<input id="contact-email"
type="radio"
name="contactMethod"
model.bind="'email'"
checked.bind="contact.preferredMethod"
class="form-check-input" />
<label for="contact-email" class="form-check-label">
Email
</label>
</div>
<div class="form-check">
<input id="contact-phone"
type="radio"
name="contactMethod"
model.bind="'phone'"
checked.bind="contact.preferredMethod"
class="form-check-input" />
<label for="contact-phone" class="form-check-label">
Phone
</label>
</div>
<div class="form-check">
<input id="contact-text"
type="radio"
name="contactMethod"
model.bind="'text'"
checked.bind="contact.preferredMethod"
class="form-check-input" />
<label for="contact-text" class="form-check-label">
Text Message
</label>
</div>
</div>
</fieldset>
<!-- Accessible checkbox with detailed description -->
<div class="form-group">
<div class="form-check">
<input id="newsletter"
type="checkbox"
checked.bind="contact.subscribeNewsletter"
aria-describedby="newsletter-description"
class="form-check-input" />
<label for="newsletter" class="form-check-label">
Subscribe to newsletter
</label>
</div>
<div id="newsletter-description" class="form-text">
Receive weekly updates about new features, tips, and special offers.
You can unsubscribe at any time.
</div>
</div>
<!-- Form submission with clear feedback -->
<div class="form-actions">
<button type="submit"
class="btn btn-primary"
aria-describedby="submit-help">
<span if.bind="!isSubmitting">Submit Contact Information</span>
<span if.bind="isSubmitting">
<span class="visually-hidden">Submitting form, please wait</span>
<span aria-hidden="true">Submitting...</span>
</span>
</button>
<div id="submit-help" class="form-text">
Review your information before submitting
</div>
</div>
<!-- Live region for dynamic updates -->
<div aria-live="polite" aria-atomic="true" class="sr-only">
<span if.bind="submissionMessage">${submissionMessage}</span>
</div>
</form>
Accessible Form Validation
Implement validation feedback that works with screen readers:
import { resolve, newInstanceForScope } from '@aurelia/kernel';
import { IValidationController } from '@aurelia/validation-html';
export class AccessibleFormValidation {
contact = {
firstName: '',
email: '',
phone: '',
preferredMethod: '',
subscribeNewsletter: false
};
validationErrors: Map<string, string> = new Map();
isSubmitting = false;
submissionMessage = '';
private readonly validationController = resolve(newInstanceForScope(IValidationController));
get hasFirstNameError(): boolean {
return this.validationErrors.has('firstName');
}
get firstNameError(): string {
return this.validationErrors.get('firstName') || '';
}
// Focus management for form errors
async handleSubmit() {
this.validationErrors.clear();
this.isSubmitting = true;
try {
const result = await this.validationController.validate();
if (!result.valid) {
// Collect validation errors
result.results.forEach(error => {
if (!error.valid) {
this.validationErrors.set(error.propertyName, error.message);
}
});
// Focus first error field
this.focusFirstError();
this.announceErrors();
return;
}
// Submit form
await this.submitForm();
this.submissionMessage = 'Your contact information has been submitted successfully.';
} catch (error) {
this.submissionMessage = 'An error occurred. Please try again.';
} finally {
this.isSubmitting = false;
}
}
private focusFirstError() {
const firstErrorField = this.validationErrors.keys().next().value;
if (firstErrorField) {
const element = document.getElementById(firstErrorField);
element?.focus();
}
}
private announceErrors() {
const errorCount = this.validationErrors.size;
const announcement = errorCount === 1
? 'There is 1 error in the form. Please review and correct it.'
: `There are ${errorCount} errors in the form. Please review and correct them.`;
this.announceToScreenReader(announcement);
}
private announceToScreenReader(message: string) {
// Create a temporary live region for immediate announcements
const announcement = document.createElement('div');
announcement.setAttribute('aria-live', 'assertive');
announcement.setAttribute('aria-atomic', 'true');
announcement.className = 'sr-only';
announcement.textContent = message;
document.body.appendChild(announcement);
// Remove after announcement
setTimeout(() => {
document.body.removeChild(announcement);
}, 1000);
}
private async submitForm() {
// Simulate form submission
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('Form submitted:', this.contact);
}
}
CSS for Accessibility
Include essential accessibility styles:
/* Screen reader only content */
.visually-hidden, .sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
/* Focus indicators */
.form-control:focus,
.form-check-input:focus,
.btn:focus {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.form-control,
.form-check-input {
border: 2px solid ButtonText;
}
.form-control:focus,
.form-check-input:focus {
outline: 3px solid Highlight;
outline-offset: 2px;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.loading-spinner,
.progress-bar .progress-fill {
animation: none;
}
}
/* Error states */
.form-control[aria-invalid="true"] {
border-color: #dc3545;
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
}
.error-message {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
}
/* Required field indicators */
.required::after {
content: " *";
color: #dc3545;
}
Testing Accessibility
Include accessibility testing strategies:
export class AccessibilityTester {
// Programmatic accessibility testing
testFormAccessibility() {
const errors: string[] = [];
// Check for required labels
const inputs = document.querySelectorAll('input, textarea, select');
inputs.forEach((input: HTMLInputElement) => {
const label = document.querySelector(`label[for="${input.id}"]`);
const ariaLabel = input.getAttribute('aria-label');
const ariaLabelledBy = input.getAttribute('aria-labelledby');
if (!label && !ariaLabel && !ariaLabelledBy) {
errors.push(`Input with id "${input.id}" lacks proper labeling`);
}
});
// Check for proper heading structure
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
let previousLevel = 0;
headings.forEach((heading: HTMLElement) => {
const level = parseInt(heading.tagName.charAt(1));
if (level > previousLevel + 1) {
errors.push(`Heading level skipped: ${heading.tagName} after H${previousLevel}`);
}
previousLevel = level;
});
// Check for live regions
const liveRegions = document.querySelectorAll('[aria-live]');
if (liveRegions.length === 0) {
errors.push('No live regions found for dynamic content announcements');
}
return {
isAccessible: errors.length === 0,
errors
};
}
// Keyboard navigation testing
testKeyboardNavigation() {
const focusableElements = document.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
return {
focusableCount: focusableElements.length,
hasTrapFocus: this.checkFocusTrap(),
hasSkipLinks: !!document.querySelector('[href="#main"], [href="#content"]')
};
}
private checkFocusTrap(): boolean {
// Implementation would check if focus is properly trapped in modals/dialogs
return true; // Simplified
}
}
This comprehensive forms documentation provides production-ready patterns for all aspects of form development in Aurelia 2, from basic binding to advanced security and accessibility considerations. Each section includes real-world examples that you can adapt to your specific use cases.
Last updated
Was this helpful?